Saltar al contenido
CrossFly es 100% open sourceSíguenos en GitHub
CrossFlyCrossFly
Empezar
Todos los ejemplos
RecetasDispositivo402 líneas · 1 archivos

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.

Qué muestra esto

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.

Ejecutarlo en tu máquina

Cada ejemplo es una Window normal, así que el lanzador solo necesita su nombre.

gradlew lwjgl3:run --args="classname dev.cf.recipe.R34Odometer"
Editarlo

Java se compila antes de ejecutarse, así que el navegador no puede reconstruir una edición por su cuenta: el código que ves aquí se convirtió en JavaScript con TeaVM antes de publicarse. Copia el archivo a un proyecto y funciona sin cambios en las cuatro plataformas; el comando de arriba es toda la preparación.

Ejecutándose aquíCrossFly 0.1.32
Iniciando la aplicación…

Compilado a JavaScript por TeaVM: es el mismo código, no un vídeo de él. · Todos los ejemplos

Qué usa

Tipos del framework que toca este ejemplo. Los enlazados tienen página de referencia.

ButtonCapabilitiesCapabilityComponentDebugDeviceImageLabelMainPaintPlatformUtilsRelativeScreenStylesTextRecognitionVerticalLayoutWidgets

El código

Completo, sin recortes. Es el archivo, no un fragmento.

Crudo
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);
      }
   }
}
Para una IA

Versiones de esta página legibles por máquina, para que un modelo lea el estante en vez de adivinar la API.

Este ejemplo, en JSONEste ejemplo, en Markdown
AnteriorEmbedded web pageSiguienteDepot — offline-first CRUD