跳到内容
CrossFly 是 100% 开源在 GitHub 关注我们
CrossFlyCrossFly
开始使用
全部示例
配方设备402 行 · 1 个文件

Reading an odometer (OCR)

Show a strip, read the strip. The viewfinder is only as tall as the digits, and the recognizer gets that band and nothing else — so the speedometer and the trip meter around it never compete with the number.

它展示了什么

Reading an odometer with the camera: a live strip, one tap, a number.

The viewfinder is a strip, not a photo. It is about as tall as the row of digits on a dashboard, and that is the whole trick: the person lines the odometer up inside it, and the recognizer is handed exactly that band of the frame — not the speedometer, the trip meter, the clock or the outside temperature around it, every one of which is a number too. band is the one rectangle both sides use: the strip paints it, the read crops it, so what is read is what was seen.

The camera here is videoPreview, not a picker. A picker is a native window over the whole screen; a preview is frames, and frames are an Image like any other, painted by the strip's own onPaint — so it scrolls, clips and sits in a page. It is started by a tap and stopped when the screen goes away: a camera that turns on because a screen opened is a light coming on that nobody asked for.

TextRecognition returns lines of text; the number is this recipe's business. Dials print leading zeros, recognizers split a long run with a space and read a 0 as an O, and odometer undoes exactly those three, then keeps the longest run of digits.

在你的机器上运行

每个示例都是普通的 Window,启动器只需要它的名字。

gradlew lwjgl3:run --args="classname dev.cf.recipe.R34Odometer"
修改它

Java 是提前编译的,浏览器无法自行重建你的修改 —— 这里的代码在发布前已由 TeaVM 转成 JavaScript。把文件复制到项目里,它在四个平台上原样运行;上面那条命令就是全部准备工作。

正在此处运行CrossFly 0.1.32
正在启动应用…

由 TeaVM 编译为 JavaScript —— 是同一份代码,不是录像。 · 全部示例

它用到了什么

本示例触及的框架类型。带链接的有参考页。

ButtonCapabilitiesCapabilityComponentDebugDeviceImageLabelMainPaintPlatformUtilsRelativeScreenStylesTextRecognitionVerticalLayoutWidgets

源码

完整无删减。这就是那个文件,不是片段。

原始
dev/cf/recipe/R34Odometer.java
package dev.cf.recipe;

import java.io.ByteArrayOutputStream;

import dev.cf.Tones;

import dev.crossfly.Capability;
import dev.crossfly.Main;
import dev.crossfly.PlatformUtils;
import dev.crossfly.capability.Capabilities;
import dev.crossfly.capability.TextRecognition;
import dev.crossfly.device.Device;
import dev.crossfly.ui.*;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.image.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.style.*;
import dev.crossfly.util.Debug;

import static dev.crossfly.ui.Widgets.*;

/**
 * Reading an odometer with the camera: a live strip, one tap, a number.
 *
 * <p><b>The viewfinder is a strip, not a photo.</b> It is about as tall as the row of digits on a
 * dashboard, and that is the whole trick: the person lines the odometer up inside it, and the
 * recognizer is handed <b>exactly that band</b> of the frame &mdash; not the speedometer, the
 * trip meter, the clock or the outside temperature around it, every one of which is a number too.
 * {@link #band} is the one rectangle both sides use: the strip paints it, the read crops it, so what
 * is read is what was seen.</p>
 *
 * <p>The camera here is {@link PlatformUtils#videoPreview}, not a picker. A picker is a native
 * window over the whole screen; a preview is frames, and frames are an {@link Image} like any other,
 * painted by the strip's own {@code onPaint} &mdash; so it scrolls, clips and sits in a page. It is
 * started by a tap and stopped when the screen goes away: a camera that turns on because a screen
 * opened is a light coming on that nobody asked for.</p>
 *
 * <p>{@link TextRecognition} returns lines of text; the number is this recipe's business. Dials
 * print leading zeros, recognizers split a long run with a space and read a 0 as an O, and
 * {@link #odometer} undoes exactly those three, then keeps the longest run of digits.</p>
 */
public class R34Odometer extends Recipe
{
   /** The strip's height, in font heights: one row of dashboard digits and a little margin. */
   private static final float STRIP = 3.5f;

   /** Below this height the band is enlarged before it is read: recognizers want tall glyphs. */
   private static final int MIN_OCR_HEIGHT = 160;

   private Viewfinder strip;
   private Button go;
   private Label value, status;

   /** Off, live, reading, or showing what was read over the frozen frame. */
   private enum State { OFF, LIVE, READING, DONE }

   private State state = State.OFF;

   public String title()
   {
      return "Reading an odometer (OCR)";
   }

   public String note()
   {
      return "Show a strip, read the strip. The viewfinder is only as tall as the digits, and the"
         + " recognizer gets that band and nothing else — so the speedometer and the trip meter"
         + " around it never compete with the number.";
   }

   public void build(Component body) throws Exception
   {
      strip = new Viewfinder();
      strip.getStyle()
         .backColor(0xFF000000)
         .borderRadius(Styles.borderRadius)
         .autoHeight(Relative.ofFont(STRIP));
      strip.testTag("odometer.strip");
      body.add(LAY_AUTO, outgap(strip, 0, 0, 0, .8f));

      go = Tones.action("Open camera");
      go.testTag("odometer.go");
      go.onAction(e -> tap());
      body.add(LAY_AUTO, outgap(go, 0, 0, 0, 1.2f));

      // The reading is pinned below the scrolling body: with the strip and the button above it, it
      // sat under the fold and the person had to scroll to see what was read (14/10/2026).
      Component result = new Component();
      result.setLayout(new VerticalLayout(Position.START, Position.START));
      result.add(LAY_AUTO, label("ODOMETER", 1f, true, Styles.captionColor, Position.START));
      value = label("—", 3f, true, Styles.textColor, Position.START);
      value.testTag("odometer.value");
      result.add(LAY_AUTO, value);

      status = label("", 1f, false, Styles.captionColor, Position.START);
      status.testTag("odometer.status");
      result.add(LAY_AUTO, outgap(status, 0, .5f, 0, 0));
      pin(result);

      // Ask both before offering either. The camera alone is a viewfinder that reads nothing; the
      // recognizer alone has nothing to read.
      boolean cam = PlatformUtils.isSupported(Capability.VIDEO_PREVIEW);
      boolean ocr = PlatformUtils.isSupported(Capability.TEXT_RECOGNITION);
      if (!cam || !ocr)
      {
         go.setEnabled(false);
         // On an iPhone the recognizer is Apple's Vision, from iOS 13: say so, or "this device" reads
         // like a broken one (an iPhone 5s on iOS 12, 17/10/2026).
         say(!cam ? "No camera preview on this device."
            : "No text recognizer on this device." + (Device.platform != null && Device.platform.isIOS()
               ? " On iOS it needs iOS 13 or later." : ""));
      }
      else
         say("Point the camera at the odometer and fit the digits inside the strip.");
   }

   /** The one button walks the states: open the camera, read, and read again. */
   private void tap()
   {
      if (state == State.LIVE)
         read();
      else if (state != State.READING)
         start();
   }

   private void start()
   {
      strip.frame = null;
      try
      {
         // Ask for more pixels than the strip shows: the band is a slice of the frame, and the
         // recognizer reads that slice, not the screen.
         if (!PlatformUtils.instance.videoPreview(1280, 720, strip::onFrame))
         {
            say("The camera did not open.");
            return;
         }
      }
      catch (Exception e)
      {
         Debug.handle(e);
         say("The camera did not open: " + e.getMessage());
         return;
      }
      setState(State.LIVE, "Read");
      say("Fit the digits inside the strip, then tap Read.");
   }

   private void stop()
   {
      try
      {
         PlatformUtils.instance.videoPreview(0, 0, null);
      }
      catch (Exception e)
      {
         Debug.handle(e);
      }
   }

   private void read()
   {
      Image frame = strip.frame;
      if (frame == null)
      {
         say("No picture from the camera yet.");
         return;
      }
      // Stop first: the frame stays on the strip, frozen, and no camera thread writes into it
      // while it is being cut.
      stop();
      byte[] png;
      try
      {
         png = cut(frame, strip.getWidth(), strip.getHeight());
      }
      catch (Exception e)
      {
         Debug.handle(e);
         say("Could not cut the picture: " + e.getMessage());
         setState(State.DONE, "Try again");
         return;
      }
      setState(State.READING, "Reading…");
      say("Reading…");
      // -Dcrossfly.ocr.debug=1 keeps the exact PNG the recognizer gets: when nothing is read, the
      // picture is what tells a bad cut or a blurred frame from a recognizer that cannot read.
      if ("1".equals(System.getProperty("crossfly.ocr.debug")))
         try (java.io.FileOutputStream o = new java.io.FileOutputStream("ocr-" + System.currentTimeMillis() + ".png"))
         {
            o.write(png);
         }
         catch (Exception e)
         {
            Debug.log("[odometer] debug dump failed: " + e);
         }
      Capabilities.textRecognition().recognize(png, (result, error) ->
      {
         // Arrives on the paint thread, so the labels can be touched directly.
         setState(State.DONE, "Read again");
         if (error != null)
         {
            show("—");
            say("The recognizer could not read the picture: " + error);
            return;
         }
         String n = odometer(result.text);
         show(n == null ? "—" : n);
         say(n == null
            ? (result.text.isEmpty() ? "No text in the strip." : "No number in: " + result.text.replace('\n', ' '))
            : "Read: " + result.text.replace('\n', ' '));
      });
   }

   /**
    * The rectangle of a frame that a viewfinder of the given size shows, centred, cut to fill
    * ("cover"): the strip paints this and the read crops this, so the two cannot disagree.
    *
    * @param imgW  the frame's width
    * @param imgH  the frame's height
    * @param viewW the viewfinder's width
    * @param viewH the viewfinder's height
    * @return {x, y, w, h} in the frame's pixels
    */
   public static int[] band(int imgW, int imgH, int viewW, int viewH)
   {
      float scale = Math.max(viewW / (float) imgW, viewH / (float) imgH);
      int w = Math.min(imgW, Math.max(1, Math.round(viewW / scale)));
      int h = Math.min(imgH, Math.max(1, Math.round(viewH / scale)));
      return new int[]{(imgW - w) / 2, (imgH - h) / 2, w, h};
   }

   /**
    * Cuts the band a viewfinder shows out of a frame and encodes it for the recognizer, enlarged
    * when it is short &mdash; a 60-pixel strip of digits is legible to a person and too small for
    * most recognizers.
    *
    * @param frame the camera frame
    * @param viewW the viewfinder's width
    * @param viewH the viewfinder's height
    * @return a PNG of the band
    * @throws Exception when the image cannot be cut or encoded
    */
   public static byte[] cut(Image frame, int viewW, int viewH) throws Exception
   {
      int[] b = band(frame.getWidth(), frame.getHeight(), viewW, viewH);
      Image area = frame.getArea(b[0], b[1], b[2], b[3]);
      Image big = b[3] < MIN_OCR_HEIGHT ? area.resizeHeight(MIN_OCR_HEIGHT) : area;
      try
      {
         ByteArrayOutputStream out = new ByteArrayOutputStream();
         big.toPng(out);
         return out.toByteArray();
      }
      finally
      {
         if (big != area)
            big.dispose();
         area.dispose();
      }
   }

   /**
    * The odometer's number in what the recognizer read: the longest run of digits.
    * <p>
    * A space, a dot or a comma with one to three digits before it and exactly three after is a
    * thousands gap ("48 213", "48.213"), not the end of the number; anything else is a second
    * number, like the trip meter's "123.4" beside "048213". An O or an I/l touching a digit is a 0 or a 1. Leading
    * zeros are the dial's padding and go.
    *
    * @param text what the recognizer read, lines joined by {@code '\n'}
    * @return the number, or null when there is no digit
    */
   public static String odometer(String text)
   {
      String best = "";
      for (String line : text.split("\n"))
      {
         StringBuilder run = new StringBuilder();
         int since = 0; // digits since the run began or since its last thousands gap
         for (int i = 0; i <= line.length(); i++)
         {
            char c = i < line.length() ? line.charAt(i) : '\n';
            boolean nextDigit = i + 1 < line.length() && digit(line.charAt(i + 1)) >= 0;
            int d = digit(c);
            if (d >= 0 && (Character.isDigit(c) || run.length() > 0 || nextDigit))
            {
               run.append((char) ('0' + d));
               since++;
            }
            else if ((c == ' ' || c == '.' || c == ',') && since >= 1 && since <= 3 && group(line, i + 1) == 3)
               since = 0;
            else
            {
               if (run.length() > best.length())
                  best = run.toString();
               run.setLength(0);
               since = 0;
            }
         }
      }
      if (best.isEmpty())
         return null;
      int z = 0;
      while (z < best.length() - 1 && best.charAt(z) == '0')
         z++;
      return best.substring(z);
   }

   /** How many digits (or look-alikes) start at {@code from}. */
   private static int group(String line, int from)
   {
      int i = from;
      while (i < line.length() && digit(line.charAt(i)) >= 0)
         i++;
      return i - from;
   }

   /** A digit, or a letter recognizers mistake for one; -1 for anything else. */
   private static int digit(char c)
   {
      if (c >= '0' && c <= '9')
         return c - '0';
      if (c == 'O' || c == 'o')
         return 0;
      if (c == 'I' || c == 'l')
         return 1;
      return -1;
   }

   private void setState(State s, String button)
   {
      state = s;
      go.setText(button);
      go.setEnabled(s != State.READING);
      go.setValidLayout(false);
      Screen.repaint();
   }

   private void show(String s)
   {
      value.setText(s);
      value.invalidateMeasure(); // the pinned band must grow with it (see Recipe.pin)
      Screen.repaint();
   }

   private void say(String s)
   {
      // To the log too: a person reading this over ssh, or a log after the fact, sees the verdict.
      Debug.log("[odometer] " + s);
      status.setText(s);
      status.invalidateMeasure(); // the pinned band must grow with it (see Recipe.pin)
      Screen.repaint();
   }

   @Override
   public void onRemove()
   {
      if (state == State.LIVE)
         stop();
      state = State.OFF;
      super.onRemove();
   }

   /** The strip: the band of the latest frame, and a guide over it. */
   private final class Viewfinder extends Component
   {
      /** The latest frame; written on the camera's thread, read on the paint thread. */
      volatile Image frame;

      void onFrame(Image f)
      {
         // The pixels changed under the same Image: drop the texture so the next paint uploads them.
         f.freeTexture();
         boolean first = frame == null;
         frame = f;
         if (first)
            Main.runOnPaintThread(() -> say("Fit the digits inside the strip, then tap Read."));
         Screen.repaint();
      }

      @Override
      public void onPaint(Paint paint) throws Exception
      {
         super.onPaint(paint);
         Image f = frame;
         int w = getWidth(), h = getHeight();
         if (f == null || w <= 0 || h <= 0)
            return;
         int[] b = band(f.getWidth(), f.getHeight(), w, h);
         paint.image(f, 0, 0, w, h, b[0], b[1], b[2], b[3]);
         // The guide: where the digits go. Inset, so it reads as "inside" and not as a border.
         int in = Styles.fontHeight / 3, t = Math.max(2, Styles.fontHeight / 10);
         int c = state == State.DONE ? 0xFFFFFFFF : Tones.accent().color();
         paint.line(in, in, w - in, in, c, t);
         paint.line(in, h - in - t, w - in, h - in - t, c, t);
         paint.line(in, in, in, h - in, c, t);
         paint.line(w - in - t, in, w - in - t, h - in, c, t);
      }
   }
}
给 AI 助手

本页的机器可读版本,让模型直接读取示例集,而不是猜测 API。

本示例(JSON)本示例(Markdown)
上一个Embedded web page下一个Depot — offline-first CRUD