【Jack实战】如何用 Core Vision Kit 给旅行票据做端侧 OCR 识别

大家好,我是鸿蒙Jack。本期以我的《时光旅记》APP 为例,聊一下我怎么用 @kit.CoreVisionKit 的通用文字识别能力,把旅行票据图片自动识别成可编辑的行程信息。

《时光旅记》的旅行模块里有一个"旅行票夹"。用户可以把火车票、飞机票、酒店确认单、门票等凭证集中收纳到某个旅行计划下面。这里真正麻烦的地方不是"保存一条票据记录",而是用户手动填写成本太高:车次、航班号、出发到达、日期、座位、酒店地址,字段很多,而且大多数信息已经在截图或照片里。

所以我在票据编辑面板里加了一个入口:选择图片并智能识别。用户从相册选择一张票据图片后,APP 在端侧把图片解码成 PixelMap,调用 Core Vision Kit 的 textRecognition.recognizeText(),拿到整段 OCR 文本,再结合《时光旅记》的票据模型做字段解析和回填。

官方文档入口是:textRecognition(文字识别)

我在《时光旅记》里的使用场景

旅行详情页里有一个"旅行票夹"卡片。没有票据时,我会提示"添加票据图片后,可用端侧 OCR 自动识别关键信息"。用户点加号进入票据编辑面板,面板顶部有"选择图片并智能识别"按钮。


整个业务流是这样:
旅行详情页
旅行票夹
添加或编辑票据
选择图片并智能识别
PhotoViewPicker 选择相册图片
CoreFileKit 打开 URI
ImageKit 解码 PixelMap
CoreVisionKit textRecognition.recognizeText
获得 OCR 原始文本
按业务规则解析票据字段
回填票据草稿
用户检查并保存
TravelTicketItem 持久化

我这里没有把 OCR 结果直接保存。原因很简单:OCR 可能把 G101 识别成 G1O1,也可能因为图片角度或压缩质量漏掉座位号。我的设计是"先识别,后检查,再保存"。识别结果会进入草稿字段,用户确认后才写入 TravelTicketItem

Core Vision Kit OCR 的最小链路

通用文字识别的链路很清楚。

先导入模块:

arkts 复制代码
import { textRecognition } from '@kit.CoreVisionKit';

页面出现时初始化 OCR 服务,页面消失时释放:

arkts 复制代码
await textRecognition.init();
await textRecognition.release();

真正识别时,需要把图片变成 PixelMap。我的票据图片来自相册选择器,所以流程是:
票据解析逻辑 textRecognition ImageKit CoreFileKit PhotoViewPicker TravelPlanDetailPage 用户 票据解析逻辑 textRecognition ImageKit CoreFileKit PhotoViewPicker TravelPlanDetailPage 用户 点击选择图片并智能识别 select({ IMAGE_TYPE, maxSelectNumber: 1 }) 返回 photoUris[0] open(uri, READ_ONLY) 返回 fd createImageSource(fd) ImageSource createPixelMap() PixelMap recognizeText({ pixelMap }, config) TextRecognitionResult.value applyTicketOcrText(rawText) 回填草稿字段 release PixelMap close file

TextRecognitionConfiguration 里我打开了朝向检测:

arkts 复制代码
let configuration: textRecognition.TextRecognitionConfiguration = {
  isDirectionDetectionSupported: true
};

票据照片经常不是规整扫描图,可能是手机拍屏、聊天截图、横竖方向不一致的订单图。打开朝向检测会更稳。如果你的业务能保证图片一定是正向的,可以设成 false 换一点性能。

字段解析要贴着业务写

OCR 只能告诉我"图里有哪些文字",它并不知道《时光旅记》里的票据模型。项目里的 TravelTicketItem 包含这些字段:类型、标题、承运方或酒店、票号或订单号、乘客、出发到达、开始结束时间、座位、地址、备注和 ocrText

所以我把识别分成两层:

第一层是通用 OCR,只负责把图片变成文本。

第二层是旅行票据解析,只负责把文本变成业务草稿。比如看到"乘车日期""检票口""车厢座位"时偏向火车票;看到航班号、登机口、座位号时偏向飞机票;看到"酒店""入住""地址"时偏向酒店确认单。

这里不要追求一次解析百分百正确。票据 OCR 的产品体验重点是减少输入量,不是替用户绕过确认。我的处理规则是:只在草稿字段为空时自动填充,用户已经手填的内容不会被 OCR 覆盖。

完整代码

下面这份代码把《时光旅记》里的 OCR 主链路整理成一个可复用版本。如果要在多个页面复用,可以按这个结构抽成工具类。

JackTravelTicketOcr.ets

arkts 复制代码
import { Context } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { fileIo as fs } from '@kit.CoreFileKit';
import { image } from '@kit.ImageKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { textRecognition } from '@kit.CoreVisionKit';

export class TravelTicketDraft {
  type: string = '火车票';
  title: string = '';
  provider: string = '';
  identifier: string = '';
  passengerName: string = '';
  departure: string = '';
  arrival: string = '';
  startTime: string = '';
  endTime: string = '';
  seatInfo: string = '';
  address: string = '';
  note: string = '';
  ocrText: string = '';
}

export class JackTravelTicketOcr {
  async init(): Promise<void> {
    try {
      await textRecognition.init();
    } catch (error) {
      console.error(`Failed to init travel ticket OCR: ${JSON.stringify(error)}`);
    }
  }

  async release(): Promise<void> {
    try {
      await textRecognition.release();
    } catch (error) {
      console.error(`Failed to release travel ticket OCR: ${JSON.stringify(error)}`);
    }
  }

  async selectImageAndRecognize(_context: Context): Promise<string> {
    let picker: photoAccessHelper.PhotoViewPicker = new photoAccessHelper.PhotoViewPicker();
    let result: photoAccessHelper.PhotoSelectResult = await picker.select({
      MIMEType: photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE,
      maxSelectNumber: 1
    });
    if (result.photoUris.length === 0) {
      return '';
    }
    return await this.recognizeTextFromUri(result.photoUris[0]);
  }

  async recognizeTextFromUri(uri: string): Promise<string> {
    let file = await fs.open(uri, fs.OpenMode.READ_ONLY);
    let imageSource: image.ImageSource | undefined = undefined;
    let pixelMap: image.PixelMap | undefined = undefined;
    try {
      imageSource = image.createImageSource(file.fd);
      pixelMap = await imageSource.createPixelMap();
      let visionInfo: textRecognition.VisionInfo = { pixelMap: pixelMap };
      let configuration: textRecognition.TextRecognitionConfiguration = {
        isDirectionDetectionSupported: true
      };
      let result: textRecognition.TextRecognitionResult =
        await textRecognition.recognizeText(visionInfo, configuration);
      return result.value;
    } finally {
      if (pixelMap !== undefined) {
        pixelMap.release();
      }
      if (imageSource !== undefined) {
        imageSource.release();
      }
      await fs.close(file);
    }
  }

  applyTextToDraft(rawText: string, draft: TravelTicketDraft): TravelTicketDraft {
    let text: string = rawText.trim();
    draft.ocrText = text;
    if (text.length === 0) {
      return draft;
    }

    let lines: Array<string> = this.normalizeLines(text);
    let compactText: string = text.replace(/\s+/g, ' ');
    draft.type = this.inferTicketTypeFromText(text);

    let trainNo: string = this.extractTrainNumber(compactText, lines);
    let flightNo: string = draft.type === '飞机票' ? this.extractFlightNumber(compactText, lines) : '';
    let identifier: string = this.matchFirstText(/订单号[::]?\s*([A-Z0-9]{6,24})/, compactText);
    if (identifier.length === 0 && draft.type === '火车票') {
      identifier = trainNo;
    } else if (identifier.length === 0 && draft.type === '飞机票') {
      identifier = flightNo;
    } else if (identifier.length === 0) {
      identifier = this.matchFirstText(/([A-Z]{1,3}\d{2,12})/, compactText);
    }
    this.fillIfEmpty(draft, 'identifier', identifier);

    let dateText: string = this.matchFirstText(
      /(\d{4}[-/.年]\d{1,2}[-/.月]\d{1,2}[日]?\s*\d{1,2}:\d{2})/,
      compactText
    );
    if (dateText.length === 0 && draft.type === '火车票') {
      dateText = this.combineDateAndTime(
        this.matchFirstText(/乘车日期[::]?\s*(\d{4}年\d{1,2}月\d{1,2}日)/, compactText),
        this.extractTrainDepartureTimeFromTimeBlocks(lines)
      );
    } else if (dateText.length === 0 && draft.type === '飞机票') {
      dateText = this.combineDateAndTime(
        this.extractFlightDateText(compactText, lines),
        this.extractFlightBoardingTime(lines)
      );
    }
    this.fillIfEmpty(draft, 'startTime', dateText);

    let provider: string = this.extractTicketProvider(draft.type, compactText, lines);
    if (provider.length === 0 && draft.type === '飞机票') {
      provider = this.getAirlineProviderFromFlightNumber(flightNo);
    }
    this.fillIfEmpty(draft, 'provider', provider);

    if (draft.type === '飞机票') {
      this.fillIfEmpty(draft, 'passengerName', this.extractFlightPassengerName(lines, compactText));
    }

    let route: Array<string> = draft.type === '飞机票'
      ? [this.extractFlightDeparture(lines), this.extractFlightArrival(compactText, lines)]
      : this.extractTicketRoute(compactText, lines);
    if (route[0].length === 0 && draft.type === '火车票') {
      route = this.extractTrainRouteFromTimeBlocks(lines);
    }
    this.fillIfEmpty(draft, 'departure', route[0]);
    this.fillIfEmpty(draft, 'arrival', route[1]);

    let seatInfo: string = draft.type === '飞机票'
      ? this.buildFlightSeatSummary(this.extractFlightGateInfo(lines), this.extractFlightSeatNumber(lines, compactText))
      : this.extractTicketSeatInfo(compactText);
    this.fillIfEmpty(draft, 'seatInfo', seatInfo);

    this.fillIfEmpty(draft, 'address', this.findLine(lines, ['地址', '集合地点']));

    if (draft.title.trim().length === 0) {
      if (draft.type === '飞机票' && flightNo.length > 0 && draft.departure.length > 0 && draft.arrival.length > 0) {
        draft.title = `${flightNo} ${draft.departure}-${draft.arrival}`;
      } else if (trainNo.length > 0 && draft.departure.length > 0 && draft.arrival.length > 0) {
        draft.title = `${trainNo} ${draft.departure}-${draft.arrival}`;
      } else {
        draft.title = this.buildTicketTitleFromDraft(draft);
      }
    }

    if (draft.note.trim().length === 0) {
      draft.note = text.length > 220 ? text.substring(0, 220) : text;
    }

    return draft;
  }

  private fillIfEmpty(draft: TravelTicketDraft, key: string, value: string): void {
    let normalizedValue: string = value.trim();
    if (normalizedValue.length === 0) {
      return;
    }
    if (key === 'identifier' && draft.identifier.trim().length === 0) {
      draft.identifier = normalizedValue;
    } else if (key === 'startTime' && draft.startTime.trim().length === 0) {
      draft.startTime = normalizedValue;
    } else if (key === 'provider' && draft.provider.trim().length === 0) {
      draft.provider = normalizedValue;
    } else if (key === 'passengerName' && draft.passengerName.trim().length === 0) {
      draft.passengerName = normalizedValue;
    } else if (key === 'departure' && draft.departure.trim().length === 0) {
      draft.departure = normalizedValue;
    } else if (key === 'arrival' && draft.arrival.trim().length === 0) {
      draft.arrival = normalizedValue;
    } else if (key === 'seatInfo' && draft.seatInfo.trim().length === 0) {
      draft.seatInfo = normalizedValue;
    } else if (key === 'address' && draft.address.trim().length === 0) {
      draft.address = normalizedValue;
    }
  }

  private inferTicketTypeFromText(text: string): string {
    let compactText: string = text.replace(/\s+/g, ' ');
    if (/登机口|航班|BOARDING|GATE|Flight|航站楼|机票/.test(compactText)) {
      return '飞机票';
    }
    if (/车次|检票口|候车|乘车日期|火车票|高铁|动车/.test(compactText)) {
      return '火车票';
    }
    if (/酒店|入住|离店|房型|住宿|前台/.test(compactText)) {
      return '酒店';
    }
    if (/门票|景区|入园|游客|预约/.test(compactText)) {
      return '门票';
    }
    return '其他';
  }

  private normalizeLines(text: string): Array<string> {
    let sourceLines: Array<string> = text.split(/\r?\n/);
    let lines: Array<string> = [];
    for (let i: number = 0; i < sourceLines.length; i++) {
      let line: string = sourceLines[i].replace(/\s+/g, ' ').trim();
      if (line.length > 0) {
        lines.push(line);
      }
    }
    return lines;
  }

  private matchFirstText(pattern: RegExp, text: string): string {
    let match: RegExpMatchArray | null = text.match(pattern);
    if (match === null || match.length < 2) {
      return '';
    }
    return match[1].trim();
  }

  private extractTrainNumber(text: string, lines: Array<string>): string {
    let trainNo: string = this.matchFirstText(/\b([GCDZTKSY]\d{1,5})\b/, text);
    if (trainNo.length > 0) {
      return trainNo;
    }
    for (let i: number = 0; i < lines.length; i++) {
      trainNo = this.matchFirstText(/车次[::]?\s*([A-Z]\d{1,5})/, lines[i]);
      if (trainNo.length > 0) {
        return trainNo;
      }
    }
    return '';
  }

  private extractFlightNumber(text: string, lines: Array<string>): string {
    let flightNo: string = this.matchFirstText(/\b([A-Z]{2}\d{3,4})\b/, text);
    if (flightNo.length > 0) {
      return flightNo;
    }
    for (let i: number = 0; i < lines.length; i++) {
      flightNo = this.matchFirstText(/航班号?[::]?\s*([A-Z0-9]{2,8})/, lines[i]);
      if (flightNo.length > 0) {
        return flightNo;
      }
    }
    return '';
  }

  private extractTicketProvider(type: string, text: string, lines: Array<string>): string {
    if (type === '火车票') {
      if (/中国铁路|铁路12306|12306/.test(text)) {
        return '中国铁路';
      }
      return '';
    }
    if (type === '飞机票') {
      let provider: string = this.findLine(lines, ['航空', 'AIRLINES']);
      return provider.length > 30 ? '' : provider;
    }
    if (type === '酒店') {
      let hotel: string = this.findLine(lines, ['酒店', '宾馆', '民宿']);
      return hotel.length > 40 ? '' : hotel;
    }
    return '';
  }

  private getAirlineProviderFromFlightNumber(flightNo: string): string {
    if (flightNo.length < 2) {
      return '';
    }
    let prefix: string = flightNo.substring(0, 2);
    if (prefix === 'CZ') {
      return '南方航空';
    }
    if (prefix === 'MU') {
      return '东方航空';
    }
    if (prefix === 'CA') {
      return '中国国航';
    }
    if (prefix === 'HU') {
      return '海南航空';
    }
    if (prefix === 'MF') {
      return '厦门航空';
    }
    return '';
  }

  private extractTicketRoute(text: string, lines: Array<string>): Array<string> {
    let routeText: string = this.matchFirstText(/([\u4e00-\u9fa5A-Za-z]{2,20})\s*[----→至到]\s*([\u4e00-\u9fa5A-Za-z]{2,20})/, text);
    if (routeText.length > 0) {
      let routeMatch: RegExpMatchArray | null =
        text.match(/([\u4e00-\u9fa5A-Za-z]{2,20})\s*[----→至到]\s*([\u4e00-\u9fa5A-Za-z]{2,20})/);
      if (routeMatch !== null && routeMatch.length >= 3) {
        return [routeMatch[1].trim(), routeMatch[2].trim()];
      }
    }
    let from: string = this.findLine(lines, ['出发', '始发']);
    let to: string = this.findLine(lines, ['到达', '终到']);
    return [this.removeLabel(from), this.removeLabel(to)];
  }

  private extractTrainRouteFromTimeBlocks(lines: Array<string>): Array<string> {
    let stationLines: Array<string> = [];
    for (let i: number = 0; i < lines.length; i++) {
      if (this.isLikelyTrainStationLine(lines[i])) {
        stationLines.push(lines[i]);
      }
    }
    if (stationLines.length >= 2) {
      return [stationLines[0], stationLines[1]];
    }
    return ['', ''];
  }

  private isLikelyTrainStationLine(line: string): boolean {
    return /^[\u4e00-\u9fa5]{2,12}(站|东|西|南|北)?$/.test(line) && !/乘车|检票|候车|座位/.test(line);
  }

  private extractTrainDepartureTimeFromTimeBlocks(lines: Array<string>): string {
    for (let i: number = 0; i < lines.length - 1; i++) {
      if (!/^\d{1,2}:\d{2}$/.test(lines[i])) {
        continue;
      }
      for (let j: number = i + 1; j < lines.length && j <= i + 3; j++) {
        if (this.isLikelyTrainStationLine(lines[j])) {
          return lines[i];
        }
      }
    }
    return '';
  }

  private combineDateAndTime(dateText: string, timeText: string): string {
    if (dateText.length === 0 || timeText.length === 0) {
      return dateText.length > 0 ? dateText : timeText;
    }
    return `${dateText} ${timeText}`;
  }

  private extractTicketSeatInfo(text: string): string {
    let seat: string = this.matchFirstText(/([0-9A-Z]{1,4}车\s*[0-9A-Z]{1,6}号?)/, text);
    if (seat.length > 0) {
      return seat;
    }
    seat = this.matchFirstText(/(座位[::]?\s*[0-9A-Z\-]+号?)/, text);
    if (seat.length > 0) {
      return seat;
    }
    return this.matchFirstText(/(登机口[::]?\s*[0-9A-Z\-]+)/, text);
  }

  private extractFlightDateText(text: string, _lines: Array<string>): string {
    let dateText: string = this.matchFirstText(/日期[::]?\s*(\d{4}[-/.年]\d{1,2}[-/.月]\d{1,2}[日]?)/, text);
    if (dateText.length > 0) {
      return dateText;
    }
    return this.matchFirstText(/(\d{1,2}月\d{1,2}日)/, text);
  }

  private extractFlightBoardingTime(lines: Array<string>): string {
    for (let i: number = 0; i < lines.length; i++) {
      let time: string = this.matchFirstText(/(?:登机|起飞|时间)[::]?\s*(\d{1,2}:\d{2})/, lines[i]);
      if (time.length > 0) {
        return time;
      }
    }
    return '';
  }

  private extractFlightDeparture(lines: Array<string>): string {
    let value: string = this.findLine(lines, ['出发', 'FROM']);
    return this.removeLabel(value);
  }

  private extractFlightArrival(text: string, lines: Array<string>): string {
    let value: string = this.findLine(lines, ['到达', 'TO']);
    if (value.length > 0) {
      return this.removeLabel(value);
    }
    return this.matchFirstText(/到达[::]?\s*([\u4e00-\u9fa5A-Za-z]{2,20})/, text);
  }

  private extractFlightPassengerName(lines: Array<string>, text: string): string {
    let name: string = this.matchFirstText(/(?:姓名|旅客|乘客|PASSENGER)[::]?\s*([\u4e00-\u9fa5A-Za-z ]{2,30})/, text);
    if (name.length > 0) {
      return name;
    }
    for (let i: number = 0; i < lines.length; i++) {
      if (/旅客|乘客|PASSENGER/.test(lines[i]) && i + 1 < lines.length) {
        return lines[i + 1].trim();
      }
    }
    return '';
  }

  private extractFlightGateInfo(lines: Array<string>): string {
    for (let i: number = 0; i < lines.length; i++) {
      let gate: string = this.matchFirstText(/(?:登机口|GATE)[::]?\s*([A-Z0-9\-]+)/, lines[i]);
      if (gate.length > 0) {
        return `登机口 ${gate}`;
      }
    }
    return '';
  }

  private extractFlightSeatNumber(lines: Array<string>, text: string): string {
    let seat: string = this.matchFirstText(/(?:座位|SEAT)[::]?\s*([0-9A-Z\-]+)/, text);
    if (seat.length > 0) {
      return `座位 ${seat}`;
    }
    for (let i: number = 0; i < lines.length; i++) {
      seat = this.matchFirstText(/([0-9]{1,2}[A-F])/, lines[i]);
      if (seat.length > 0) {
        return `座位 ${seat}`;
      }
    }
    return '';
  }

  private buildFlightSeatSummary(gate: string, seat: string): string {
    if (gate.length > 0 && seat.length > 0) {
      return `${gate} ${seat}`;
    }
    return gate.length > 0 ? gate : seat;
  }

  private findLine(lines: Array<string>, keywords: Array<string>): string {
    for (let i: number = 0; i < lines.length; i++) {
      for (let j: number = 0; j < keywords.length; j++) {
        if (lines[i].indexOf(keywords[j]) >= 0) {
          return lines[i];
        }
      }
    }
    return '';
  }

  private removeLabel(value: string): string {
    return value
      .replace(/^(出发|始发|到达|终到|FROM|TO|地址|集合地点)[::]?\s*/i, '')
      .trim();
  }

  private buildTicketTitleFromDraft(draft: TravelTicketDraft): string {
    if (draft.departure.trim().length > 0 && draft.arrival.trim().length > 0) {
      return `${draft.departure.trim()} - ${draft.arrival.trim()}`;
    }
    if (draft.provider.trim().length > 0) {
      return `${draft.provider.trim()} ${draft.type}`;
    }
    return draft.type;
  }
}

页面接入代码

页面里只需要维护一个 OCR 实例、一个忙碌状态和一份票据草稿。下面是和《时光旅记》当前页面一致的接入方式。

arkts 复制代码
import { Context } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { promptAction } from '@kit.ArkUI';
import { JackTravelTicketOcr, TravelTicketDraft } from './JackTravelTicketOcr';

@Component
export struct TravelTicketEditorDemo {
  @State private isTicketOcrBusy: boolean = false;
  @State private ticketDraft: TravelTicketDraft = new TravelTicketDraft();
  private readonly ticketOcr: JackTravelTicketOcr = new JackTravelTicketOcr();

  aboutToAppear(): void {
    void this.ticketOcr.init();
  }

  aboutToDisappear(): void {
    void this.ticketOcr.release();
  }

  build(): void {
    Column({ space: 16 }) {
      Button() {
        Row({ space: 10 }) {
          if (this.isTicketOcrBusy) {
            LoadingProgress()
              .width(20)
              .height(20)
          } else {
            SymbolGlyph($r('sys.symbol.circle_viewfinder'))
              .fontSize(20)
          }
          Text('选择图片并智能识别')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .layoutWeight(1)
        }
        .width('100%')
      }
      .width('100%')
      .height(52)
      .padding({ left: 16, right: 16 })
      .borderRadius(18)
      .enabled(!this.isTicketOcrBusy)
      .onClick(() => {
        void this.selectTicketImageAndRecognize();
      })

      TextInput({ text: this.ticketDraft.title, placeholder: '票据标题' })
        .onChange((value: string) => {
          this.ticketDraft.title = value;
        })

      TextInput({ text: this.ticketDraft.identifier, placeholder: '车次、航班号或订单号' })
        .onChange((value: string) => {
          this.ticketDraft.identifier = value;
        })

      TextInput({ text: this.ticketDraft.departure, placeholder: '出发/入住' })
        .onChange((value: string) => {
          this.ticketDraft.departure = value;
        })

      TextInput({ text: this.ticketDraft.arrival, placeholder: '到达/离店' })
        .onChange((value: string) => {
          this.ticketDraft.arrival = value;
        })

      TextArea({ text: this.ticketDraft.ocrText, placeholder: 'OCR 原文' })
        .height(120)
        .onChange((value: string) => {
          this.ticketDraft.ocrText = value;
        })
    }
    .width('100%')
    .padding(20)
  }

  private async selectTicketImageAndRecognize(): Promise<void> {
    if (this.isTicketOcrBusy) {
      return;
    }
    let hostContext: Context | undefined = this.getUIContext().getHostContext() as Context | undefined;
    if (hostContext === undefined) {
      this.showToast('无法访问当前页面上下文');
      return;
    }
    this.isTicketOcrBusy = true;
    try {
      let rawText: string = await this.ticketOcr.selectImageAndRecognize(hostContext);
      this.ticketDraft = this.ticketOcr.applyTextToDraft(rawText, this.ticketDraft);
      this.showToast(rawText.trim().length > 0 ? '已识别票据信息,请检查后保存' : '未识别到文字,可手动填写');
    } catch (error) {
      let businessError: BusinessError = error as BusinessError;
      this.showToast(
        businessError.message && businessError.message.length > 0 ? businessError.message : '票据识别失败'
      );
    } finally {
      this.isTicketOcrBusy = false;
    }
  }

  private showToast(message: string): void {
    promptAction.showToast({
      message: message,
      duration: 1600
    });
  }
}

保存到旅行票夹

识别只是填草稿,保存时再把草稿转成业务模型。项目里的 TravelTicketItem 有一个 ocrText 字段,我会把 OCR 原文一起存下来,方便用户后面回看,也方便后续优化解析规则。

arkts 复制代码
private saveTicketDraft(): void {
  let nextItem: TravelTicketItem = new TravelTicketItem();
  nextItem.type = this.ticketDraft.type;
  nextItem.title = this.ticketDraft.title.trim().length > 0 ? this.ticketDraft.title.trim() : this.ticketDraft.type;
  nextItem.provider = this.ticketDraft.provider.trim();
  nextItem.identifier = this.ticketDraft.identifier.trim();
  nextItem.passengerName = this.ticketDraft.passengerName.trim();
  nextItem.departure = this.ticketDraft.departure.trim();
  nextItem.arrival = this.ticketDraft.arrival.trim();
  nextItem.startTime = this.ticketDraft.startTime.trim();
  nextItem.endTime = this.ticketDraft.endTime.trim();
  nextItem.seatInfo = this.ticketDraft.seatInfo.trim();
  nextItem.address = this.ticketDraft.address.trim();
  nextItem.note = this.ticketDraft.note.trim();
  nextItem.ocrText = this.ticketDraft.ocrText.trim();
  nextItem.updatedAt = new Date().toISOString();

  // 这里接你的持久化逻辑。我的项目里会写入 travel_ticket_items 表,
  // 并同步更新当前 TravelPlan 的 ticketItems。
}

接入时要注意的细节

PhotoViewPicker 返回的是 URI,不是图片字节。要先用 CoreFileKit 打开,再交给 ImageKit 创建 ImageSourcePixelMap

VisionInfo 当前传的是 PixelMap。OCR 结束后要释放 PixelMap,文件句柄也要关闭。这个逻辑建议放在 finally,不要只写在成功分支。

textRecognition.init()release() 不要每识别一张图就反复调用。我的处理是进入旅行详情页时初始化,离开页面时释放。票据识别按钮只负责选择图片和识别。

OCR 原文建议保存。解析规则后续一定会迭代,有原文就能重新分析为什么某张票没填出字段。

小结

这套能力落到旅行票夹里,核心价值不是"识别出一段文字",而是把用户要填的字段提前补上。Core Vision Kit 负责端侧 OCR,业务层负责票据语义解析,最后让用户检查再保存。这样既能减少输入,又不会把 OCR 的不确定性直接写进用户数据。

相关推荐
yumgpkpm3 小时前
【华为昇腾910B】在AI大模型推理速度与GPU显卡选择中地位
大数据·人工智能·华为
枫叶丹43 小时前
【HarmonyOS 6.0】Device Security Kit 病毒防护服务管理能力解析
华为·harmonyos
木斯佳6 小时前
HarmonyOS 6 ArkGraphics 3D精讲:从旋转立方体看鸿蒙原生3D能力
3d·华为·harmonyos
nashane16 小时前
HarmonyOS 6学习:PC端悬浮窗模式与智能长截图的协同优化实战
学习·华为·harmonyos
wei_shuo18 小时前
Windows 鸿蒙 PC 应用开发:DevEco Studio 集成与调用三方 Native 库实战指南
鸿蒙·鸿蒙pc·三方库适配
阿钱真强道20 小时前
23 鸿蒙LiteOS 消息队列(Queue)实战教程:任务间数据传递详解
harmonyos·鸿蒙·消息·队列·liteos·rk2206·瑞星微
前端不太难20 小时前
AI 不只是聊天框:鸿蒙 App 新入口
人工智能·状态模式·harmonyos
leon_teacher1 天前
HarmonyOS 6 实战:基于 Ads Kit 的插屏广告(视频 + 图片)架构与实现全解析
架构·音视频·harmonyos
Hoxy.R1 天前
银河麒麟 V10 离线安装 s3cmd 踩坑记录+存储负载均衡测试
linux·运维·华为·存储