【共创稿事节】鸿蒙文搜图 · 语义相册:用一句话找到相册里的任意照片,端侧 AI 检索、相似度排序、作用域过滤

图像超分系列走完「真实照片进、真实文件出」之后,相册方向还有一个更日常的痛点没解决:找图。几千张照片里翻一张「去年海边看日落的那张」,靠人肉滚动定位基本没戏。这一篇换一个 Core Vision Kit 能力——textSearchImage 文搜图:把相册图片喂给端侧分析器建索引,之后用一句自然语言(「海边日落」「雪山」「猫」)直接检索,命中结果按语义相似度降序排列,全程 NPU 端侧推理、零联网、数据不出设备。

先说清楚这个能力的技术底色。传统相册搜索是「文本对文本」——搜文件名、搜手动备注,没打过标签的照片等于不存在。文搜图是「文本对图像」的跨模态检索:端侧模型把每张图片编码成一个语义向量,把用户输入的一句话也编码成同一语义空间里的向量,两个向量算相似度,语义越接近分越高。所以「海边日落」不需要出现在任何文件名或备注里,只要照片画面里真的有海边和日落,就能被找出来——这是打标签方案永远做不到的。

文搜图主界面:输入「海边日落」检索,结果按相似度降序网格展示

工程地址:LI_harmonyOS/text-image-finder
运行环境:HarmonyOS 7.0(API 26)+ ArkTS 严格模式,已在真机安装运行验证。

一、效果展示:一条完整的检索叙事线

完整流程只有三步:建索引 → 输入一句话 → 看结果。以下截图均为演示模式(内置 12 张真实照片素材)真机运行,真机模式走同一套 UI、只把检索内核换成 NPU。
在这里插入图片描述

第 1 步:就绪。 应用启动后端侧能力初始化完成,状态栏提示「已就绪」,结果区是空态引导——一个相机 emoji 加两行说明文字,告诉用户下一步该干什么。空态不是留白,是引导:

初始就绪态:空态引导 + 状态栏提示

第 2 步:建索引。 点「加载示例图片」:12 张照片按作用域分组登记进端侧索引,状态栏实时回显「已索引 12 张」。注意右上角的计数是全局口径——切到「宠物」域再点加载,只登记宠物域的 4 张,计数从 12 涨到 16,不会重复也不会翻倍:

加载示例图片:12 张照片登记进端侧索引

第 3 步:检索。 搜索框输入「海边日落」点搜索,350ms 内返回命中网格:每张卡片右下角是相似度角标(百分比),下方是标题与精确到小数点后两位的相似度数值,整组结果按相似度降序——语义最相关的图永远排第一:

搜索「海边日落」:相似度角标 + 降序网格

换一个域再试:切到「宠物」Tab 搜「猫」,只在宠物域的 4 张图里找,猫的相似度一骑绝尘排第一,狗、兔子、鱼这些「同为宠物但不是猫」的图以明显更低的分数跟在后面——相似度不是二值的「命中/未命中」,而是连续的「多相关」,这正是语义检索和标签过滤的本质区别:

宠物作用域搜「猫」:猫最高分,其余宠物低分跟随

二、这个 App 解决什么问题

相册找图的传统做法有三种,各有各的死角:

方案死角
按时间/地点滚动翻几千张照片里定位「去年那次海边日落」,翻到眼瞎
手动打标签/建相簿打标签的成本比找图本身还高,没人坚持得下来
按文件名搜索照片文件名是 IMG_20250816_001 这类流水号,和内容零关联

文搜图给出的答案是:建索引时端侧自动算语义特征,检索时拿文本语义和图片语义直接算相似度。用户只需要会说话——「海边日落」「雪山」「猫」——剩下的交给 NPU。这个实验台把它落成三件事:

  • 作用域(scope)分组:照片按「旅行/工作/宠物」分域建索引,检索时只在指定域内找,跨域时聚合检索,避免「搜猫翻出会议室」;
  • 相似度可量化:每张命中图带 0~1 相似度分值,UI 上同时给百分比角标和两位小数数值,用户能感知「多相关」,不只是「有没有」;
  • 零权限建索引:真机模式走系统安全选择器选图入库,module.json5 不申请任何权限——数据面(选图)和权限面(申请相册读权限)彻底解耦。

三、交互设计:一个状态字段驱动的三态界面

页面没有路由跳转,全部状态收在一个 @State 集合里,build() 按数据渲染:

  @State keyword: string = '';
  @State loading: boolean = false;
  @State ready: boolean = false;
  @State indexedCount: number = 0;
  @State statusText: string = '正在初始化文搜图能力…';
  @State currentScope: string = SCOPE_ALL;
  @State resultList: SearchHit[] = [];

七个字段各管一摊:keyword 绑搜索框、loading 管检索态、ready 管能力就绪、indexedCount 管索引计数、statusText 是全局状态栏的唯一文案源、currentScope 管作用域、resultList 管结果。没有第二个真相来源,UI 任何时刻的画面都是这七个字段的纯函数。

三个关键状态:

  • 就绪态:aboutToAppear 里 init() 完成后 ready = true,状态栏提示「已就绪,点击「加载示例图片」后即可搜索」,结果区显示空态引导;
  • 检索态:loading = true 时搜索按钮禁用、状态栏 LoadingProgress 转圈 + 「正在检索"xxx"…」,防止重复触发;
  • 结果态:resultList 非空渲染网格,空则状态栏给「暂无结果,换个说法试试」——空结果也是一等公民,不是报错。

3.1 防重入:loading 是闸门不是装饰

doSearch 的前两道检查决定了它永远不会被并发触发:能力未就绪直接 toast 返回,关键词为空(含纯空格,trim() 兜住)也直接返回。进入检索后 loading = true 把按钮闸门关上,finally 里无论成败都复位——异步流程的收口永远放 finally,不放 then,否则一次异常就把按钮永久锁死:

  /** 执行搜索 */
  private async doSearch(): Promise<void> {
    if (!this.ready) {
      this.toast('能力未就绪');
      return;
    }
    const query: string = this.keyword.trim();
    if (!query) {
      this.toast('请输入描述文字');
      return;
    }
    this.loading = true;
    this.statusText = `正在检索“${query}”…`;
    try {
      const hits: SearchHit[] = await this.service.search(query, this.currentScope, 12);
      this.resultList = hits;
      this.statusText = hits.length > 0
        ? `找到 ${hits.length} 张相关图片`
        : '暂无结果,换个说法试试';
    } catch (e) {
      this.statusText = '检索失败,请重试';
    } finally {
      this.loading = false;
    }
  }

注意状态栏文案的四种结局:找到 N 张 / 暂无结果 / 检索失败 / 正在检索——每一条异步路径都有专属文案,用户永远知道系统在干什么、干成了还是没干成。

3.2 快捷词:把演示成本压到一次点击

搜索框上方加了一排演示快捷词(海边日落/雪山/代码/猫),点一下即填入并自动检索——演示和体验都省去打字:

  /** 演示快捷词:一键填入并检索,免手打 */
  private quickWords: string[] = ['海边日落', '雪山', '代码', '猫'];

  /** 快捷词点击:填入关键词并直接检索 */
  private onQuickWord(word: string): void {
    this.keyword = word;
    this.doSearch();
  }

四个词不是随手选的:海边日落 演示多标签强命中(高分)、雪山 演示域内命中与域外空态的对比、代码 演示工作域检索、猫 演示相似度的梯度分布——每个快捷词对应一个要演示的技术点。

3.3 作用域 Tab:切域即清结果

顶部一排作用域 Tab(全部/旅行/工作/宠物)只改 currentScope 并清空上次结果,不需要回主页重来。点当前已选中的 Tab 直接短路返回,不触发任何状态变化:

  @Builder
  scopeTab(item: ScopeItem) {
    Button(item.label)
      .width('auto')
      .height(36)
      .padding({ left: 18, right: 18 })
      .borderRadius(18)
      .fontSize(14)
      .fontColor(this.currentScope === item.key ? '#FFFFFF' : '#2D6CFF')
      .backgroundColor(this.currentScope === item.key ? '#2D6CFF' : '#E8F0FF')
      .onClick(() => {
        if (this.currentScope === item.key) {
          return;
        }
        this.currentScope = item.key;
        this.resultList = [];
      })
  }

清空结果是个刻意的设计:旧域的检索结果对新域来说是脏数据——在「旅行」域搜出的雪山结果,切到「宠物」域后如果还挂在页面上,用户会误以为宠物域里有雪山。宁可让用户再点一次搜索,也不给错误的上下文。


四、工程结构

text-image-finder/
├── entry/src/main/
│   ├── ets/
│   │   ├── pages/Index.ets               # 三态页面、scope Tab、快捷词、结果网格
│   │   └── common/
│   │       ├── TextImageSearchService.ets # 单例服务:init/导入建索引/检索/清空/释放
│   │       ├── DemoData.ets              # 12 张示例图:scope + 标签 + rawfile 路径
│   │       └── scopeConfig.ets           # 作用域配置与中文标签映射
│   └── resources/rawfile/demo/           # 12 张真实照片素材(旅行/工作/宠物)

三层职责切得很干净:

层文件职责不知道的事
页面层Index.ets状态机、布局、交互反馈不知道检索是 NPU 还是标签匹配
服务层TextImageSearchService.ets索引生命周期、匹配、排序不知道 UI 有哪些状态
数据层DemoData.ets + scopeConfig.ets素材定义、域配置不知道自己被谁消费

UI 只调 init / importFromAlbum / search / clearData / release 五个方法,完全不感知底下是 NPU 语义推理还是演示标签匹配——和超分系列「双模式」同一套设计哲学。接口即防火墙:只要 SearchHit 的字段结构不变,换内核 UI 一行不改。


五、核心实现

5.1 作用域:把相册分成三个「小相册」

textSearchImage 的 scope 是底层分组标识,仅允许 1~32 位字母或数字,中文只能放在 UI 层。所以配置里 key/label 分离,另留一个特殊值 all 表示跨域聚合:

/** 全部(特殊值:检索时跨所有业务 scope 聚合) */
export const SCOPE_ALL: string = 'all';

/** 业务作用域列表(不含“全部”) */
export const SCOPES: ScopeItem[] = [
  { key: SCOPE_ALL, label: '全部' },
  { key: 'travel', label: '旅行' },
  { key: 'work', label: '工作' },
  { key: 'pet', label: '宠物' }
];

导入时按 scope 分组入库,检索时 all 走全量、其他走过滤——一进一出两侧都收口在服务层,UI 永远只传一个 scope 字符串。scopeLabel() 反查函数负责 key→中文的显示转换,状态栏文案里「已载入 4 张示例图(宠物)」的括号内容就来自它。

5.2 数据层:12 张图和它们的同义词矩阵

每张演示图的定义是四元组:id、rawfile 路径、scope、标签数组。标签数组是整个 Mock 引擎的灵魂——它不是简单的一个词,而是同义词矩阵:

export const DEMO_IMAGES: DemoImage[] = [
  // —— 旅行 travel ——
  { id: 'sea', rawfile: 'demo/sea_sunset.png', scope: 'travel', title: '海边日落', tags: ['海边', '海', '日落', '夕阳', '沙滩', '风景', '旅行', '大海'] },
  { id: 'snow', rawfile: 'demo/snow_mountain.png', scope: 'travel', title: '雪山', tags: ['雪山', '雪', '山', '冬天', '风景', '旅行', '滑雪'] },
  { id: 'city', rawfile: 'demo/city_night.png', scope: 'travel', title: '城市夜景', tags: ['城市', '夜景', '夜', '灯光', '建筑', '旅行', '街道'] },
  { id: 'forest', rawfile: 'demo/forest.png', scope: 'travel', title: '森林', tags: ['森林', '树', '绿色', '自然', '风景', '旅行', '徒步'] },

  // —— 工作 work ——
  { id: 'desk', rawfile: 'demo/desk_computer.png', scope: 'work', title: '电脑桌面', tags: ['电脑', '桌面', '笔记本', '办公', '工作', '键盘', '屏幕', '显示器'] },
  { id: 'meeting', rawfile: 'demo/meeting_room.png', scope: 'work', title: '会议室', tags: ['会议', '会议室', '工作', '讨论', '白板', '团队', '办公'] },
  { id: 'whiteboard', rawfile: 'demo/whiteboard.png', scope: 'work', title: '白板', tags: ['白板', '工作', '计划', '笔记', '会议', '写字'] },
  { id: 'code', rawfile: 'demo/code.png', scope: 'work', title: '代码', tags: ['代码', '编程', '开发', '工作', '电脑', '屏幕'] },

  // —— 宠物 pet ——
  { id: 'cat', rawfile: 'demo/cat.png', scope: 'pet', title: '猫', tags: ['猫', '宠物', '喵', '动物', '可爱', '小猫'] },
  { id: 'dog', rawfile: 'demo/dog.png', scope: 'pet', title: '狗', tags: ['狗', '宠物', '汪', '动物', '可爱', '小狗'] },
  { id: 'rabbit', rawfile: 'demo/rabbit.png', scope: 'pet', title: '兔子', tags: ['兔子', '宠物', '动物', '可爱', '小兔'] },
  { id: 'fish', rawfile: 'demo/fish.png', scope: 'pet', title: '鱼', tags: ['鱼', '宠物', '动物', '水', '金鱼'] }
];

看「海边日落」那张图的 8 个标签:海边/海/日落/夕阳/沙滩/风景/旅行/大海——用户不管说「海」「大海」「夕阳」「沙滩」还是「海边日落」,都能命中它。这就是在 Mock 里尽量逼近真实语义检索的召回能力:真实 NPU 模型天然理解「夕阳约等于日落」,Mock 用同义词矩阵人工模拟这层理解。跨域词也刻意埋了钩子:旅行 同时出现在 4 张旅行图的标签里,搜「旅行」能一次召回整个旅行域。

5.3 建索引:导入即登记,幂等去重

真机模式通过系统安全选择器选图后拷贝进沙箱、逐张 insertImage 建索引;演示模式把 12 张示例图按 scope 登记进内存索引,接口完全一致:

  public async importFromAlbum(scope: string, maxNumber: number = 20): Promise<number> {
    if (!this.initialized) {
      throw new Error('尚未初始化');
    }
    const targets: DemoImage[] = scope === SCOPE_ALL
      ? DEMO_IMAGES
      : DEMO_IMAGES.filter((d: DemoImage) => d.scope === scope);

    let added: number = 0;
    for (const d of targets) {
      if (!this.imported.some((x: DemoImage) => x.id === d.id)) {
        this.imported.push(d);
        this.indexedCount++;
        added++;
      }
      if (this.imported.length >= maxNumber) {
        break;
      }
    }
    hilog.info(DOMAIN, TAG, 'mock import scope=%{public}s added=%{public}d', scope, added);
    return added;
  }

三个细节:

  • 幂等去重:some 按 id 判重,重复点「加载」不会翻倍计数——先在「全部」域载入 12 张,再切「宠物」域点加载,4 张宠物图已在索引里,added 为 0,状态栏如实提示「没有可载入的示例图」而不是假装又载入了 4 张;
  • maxNumber 上限:默认 20,防止一次灌爆索引——真机上相册选图一次选几百张,不设上限会把端侧索引和内存同时打爆;
  • 返回 added 而不是总数:UI 能区分「载入了 N 张」和「没有可载入的示例图」两种结果,增量语义比总量语义信息量更大。

5.4 检索:文本语义 vs 图片语义

检索主流程先按 scope 过滤出候选池,再对每张图算匹配度、排序、截断 topN。匹配规则是双向包含:用户输入包含标签,或标签包含用户输入,都计为命中——「海」能命中「海边」,「海边日落」也能命中「海边」:

  public async search(query: string, scope: string, topKey: number = 12): Promise<SearchHit[]> {
    if (!this.initialized) {
      throw new Error('尚未初始化');
    }
    const keyword: string = query.trim();
    if (!keyword) {
      return [];
    }

    // 模拟端侧推理耗时,便于演示 loading 态
    await this.delay(350);

    const pool: DemoImage[] = scope === SCOPE_ALL
      ? this.imported
      : this.imported.filter((d: DemoImage) => d.scope === scope);

    const hits: SearchHit[] = [];
    for (const d of pool) {
      let matched: number = 0;
      for (const tag of d.tags) {
        if (keyword.includes(tag) || tag.includes(keyword)) {
          matched++;
        }
      }
      if (matched === 0) {
        continue; // 未命中标签不返回,与真实 textSearchImage 行为一致(只返回相关图)
      }
      const jitter: number = this.pseudo(d.id);
      const sim: number = 0.6 + 0.35 * Math.min(1, matched / 2) + jitter * 0.05;
      hits.push({
        imagePath: d.rawfile,
        similarity: Number(sim.toFixed(3)),
        rawfile: true,
        title: d.title
      });
    }

    hits.sort((a: SearchHit, b: SearchHit) => b.similarity - a.similarity);
    return hits.slice(0, topKey);
  }

值得展开的有五点:

  • 350ms 人工延迟:不是偷懒,是给 loading 态留展示窗口。真实 NPU 检索几十张图也在几百毫秒量级,如果 Mock 秒回,LoadingProgress 一闪而过,用户根本感知不到「系统在推理」——演示效果反而失真;
  • 未命中不返回:早期版本给未命中图一个 0.12~0.30 的「弱相关兜底分」,结果任何关键词都有返回——搜「雪山」在宠物域里也冒出四张低分图,既不真实(真实 textSearchImage 只返回相关图)也让空态 UI 永远展示不出来。改成 matched === 0 直接 continue,行为与真实 API 对齐;
  • 相似度分档:0.6 + 0.35 × min(1, matched/2) + jitter × 0.05——命中 1 个标签 0.6 起步,命中 2 个封顶 0.95。搜「海边日落」时海边图同时命中「海边」和「日落」两个标签拿高分,只命中「海」的图明显低一档,分数梯度对应语义梯度;
  • 确定性抖动:pseudo(d.id) 对 id 字符串做哈希折叠出 0~1 的小数,同一张图的分数永远稳定——不会出现这次搜 87% 下次搜 82% 的诡异跳变。用 Math.random() 固然省事,但检索结果的可复现性是演示可信度的底线;
  • 降序 + topN:sort 后 slice(0, topKey),UI 网格最多 12 张,语义最相关的永远在第一屏。

5.5 结果卡片:一张图带两个相似度口径

每张命中卡片用 Stack 把相似度角标压在图片右下角,标题行右侧再给精确到两位小数的分值——百分比管「一眼扫」,小数管「较真」:

  @Builder
  resultCard(item: SearchHit, index: number) {
    Column() {
      Stack({ alignContent: Alignment.BottomEnd }) {
        // Mock 演示:imagePath 为 rawfile 资源;真机实现时为沙箱绝对路径
        if (item.rawfile) {
          Image($rawfile(item.imagePath))
            .width('100%')
            .height(150)
            .objectFit(ImageFit.Cover)
            .borderRadius(12)
            .backgroundColor('#EDEFF3')
        } else {
          Image(item.imagePath)
            .width('100%')
            .height(150)
            .objectFit(ImageFit.Cover)
            .borderRadius(12)
            .backgroundColor('#EDEFF3')
        }

        Text(`${Math.max(0, Math.round(item.similarity * 100))}%`)
          .fontSize(12)
          .fontColor('#FFFFFF')
          .backgroundColor('#2D6CFF')
          .borderRadius(10)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .margin(8)
      }
      .width('100%')

      Row({ space: 6 }) {
        Text(item.title ?? `#${index + 1}`)
          .fontSize(13)
          .fontWeight(FontWeight.Medium)
          .fontColor('#333333')
          .layoutWeight(1)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
        Text(`${(item.similarity).toFixed(2)}`)
          .fontSize(12)
          .fontColor('#8A8F99')
      }
      .width('100%')
      .margin({ top: 6, left: 2 })
    }
    .width('100%')
  }

图片加载分两条路:rawfile: true 走 $rawfile() 读内置素材,false 走沙箱绝对路径——SearchHit 里那个 rawfile 布尔字段就是为这一步准备的,真机/演示同一张卡片组件直接复用。两个防御性细节也值得抄走:Math.max(0, ...) 兜底保证万一相似度异常,角标至少显示 0% 而不是 NaN%;标题兜底 #${index+1} 保证真机模式下没有 title 字段时卡片也不塌角标。

5.6 作用域过滤的实测效果:同一句话的两个结局

切到「旅行」域搜「雪山」,只在 4 张旅行图里找,雪山图以最高分排第一:

旅行作用域搜「雪山」:域内检索 + 相似度降序

反过来,切到「宠物」域搜「雪山」——宠物域里没有雪山,返回空数组,状态栏给出「暂无结果,换个说法试试」,结果区回落到空态引导图。空结果不是异常,是检索系统的正常输出,UI 必须为它设计专门的状态而不是弹错误:

宠物作用域搜「雪山」:无命中,空结果态

同一句「雪山」在两个域里一有一无,作用域过滤的价值直接可见:域切小了,检索既快又准,还不会串台。这组对照也是快捷词里专门放「雪山」的原因——一个词演示两种结局,比两个词各演示一种更有说服力。

5.7 生命周期成对收口

init 在 aboutToAppear 里调用,release 必须在 aboutToDisappear 里成对收口,否则退出页面后端侧索引资源不释放,下次进页面重复 init 会报「重复初始化」:

  aboutToAppear(): void {
    // 在组件生命周期中初始化端侧分析器服务(成对:aboutToDisappear 中 release)
    const context: common.Context = this.getUIContext().getHostContext() as common.Context;
    this.service.init(context).then((ok: boolean) => {
      this.ready = ok;
      this.statusText = ok ? '已就绪,点击「加载示例图片」后即可搜索' : '文搜图能力初始化失败,请确认设备已升级 HarmonyOS 7.0';
      if (ok) {
        this.indexedCount = this.service.getIndexedCount();
      }
    });
  }

  aboutToDisappear(): void {
    // 生命周期收口:释放端侧索引资源
    this.service.release();
  }

初始化失败也有专门文案——「请确认设备已升级 HarmonyOS 7.0」直接把排查方向告诉用户,而不是一句干巴巴的「初始化失败」。

Toast 统一走 this.getUIContext().getPromptAction(),避免全局 promptAction 的上下文歧义与弃用告警——ArkTS 严格模式下的标准写法:

  /** 统一 Toast 出口:走 UIContext,避免全局 promptAction 的上下文歧义与弃用告警 */
  private toast(message: string): void {
    try {
      this.getUIContext().getPromptAction().showToast({ message: message });
    } catch (e) {
      const err = e as BusinessError;
      console.error(`showToast failed, code: ${err.code}, message: ${err.message}`);
    }
  }

Toast 本身也包了 try-catch——反馈通道自己不能成为新的故障点,Toast 失败只记日志,绝不向上抛。


六、真机运行指南

  • 演示模式(默认):点「加载示例图片」载入 12 张内置真实照片素材(旅行 4 / 工作 4 / 宠物 4),标签匹配引擎出结果,任何设备都能完整跑通建索引→检索→空态全流程;
  • 真机模式:把 TextImageSearchService 内部实现替换为 RealTextImageSearchService——init 调 visionBase 建分析器、导入走 PhotoViewPicker 选图 + insertImage 建索引、search 调 textSearchImage,返回结构完全一致,UI 零改动;
  • 检索结果与索引数据全在端侧,不联网、不上传,清空索引用「清空索引」按钮(clearData),重进页面自动重新初始化。

七、踩坑清单速查表

现象原因修法
搜任何词都出低分结果未命中时给了「弱相关兜底分」matched === 0 直接 continue,与真实 API 行为对齐
空结果态永远展示不出来同上,永远有返回修掉兜底分后,空数组回落到空态引导 UI
scope 传中文报错底层标识仅允许 1~32 位字母/数字key 用 travel/work/pet,中文放 label 层
重复点「加载」计数翻倍导入未去重按 id some 判重,幂等导入
退出页面再进报「重复初始化」release 没有成对调用aboutToAppear init / aboutToDisappear release
同一张图相似度每次都变用了随机抖动基于 id 的确定性伪随机,分数稳定
Toast 告警「弃用」用了全局 promptAction走 getUIContext().getPromptAction()
结果卡片图片不显示rawfile 素材与沙箱路径混用SearchHit.rawfile 布尔分流:$rawfile() vs 绝对路径
检索中重复点搜索状态错乱loading 闸门没关loading = true 禁用按钮,finally 复位
切域后旧结果残留误导切域未清结果Tab onClick 里 resultList = []

八、总结

至此相册方向走出了「找图」这一步:一句话进、相似度排序的图片网格出。

  • 作用域把大相册切成小相册,域内检索快且不串台;
  • 相似度从「有没有」升级到「多相关」,百分比角标 + 两位小数双口径;
  • 建索引、检索全在端侧 NPU,零权限、零联网、数据不出设备;
  • 空结果是一等公民,**「暂无结果,换个说法试试」**而不是报错弹窗;
  • 状态机七字段单真相源,每条异步路径都有专属状态文案。

下一步可以往两个方向推:一是和图像超分系列打通——检索命中的低清老照片先 4× 超分再展示,「找到即增强」;二是把索引从内置素材换成真实相册全量,配合媒体库变更监听做增量索引,让「语义相册」真正接管几千张照片。两边的内核都已经在各自系列里备齐了。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐