它展示了什么
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。把文件复制到项目里,它在四个平台上原样运行;上面那条命令就是全部准备工作。
它用到了什么
本示例触及的框架类型。带链接的有参考页。
源码
完整无删减。这就是那个文件,不是片段。
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 — 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} — 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 — 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);
}
}
}package dev.cf.recipe;
import dev.cf.*;
import dev.crossfly.ui.*;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.skin.*;
import dev.crossfly.ui.style.*;
import static dev.crossfly.ui.Widgets.*;
/**
* Base of a <b>recipe</b>: one screen that shows one thing, and nothing else.
*
* <p>A recipe is not a sample. A sample shows a component with all its knobs; a recipe answers a
* question somebody actually has — "how do I put a filter sheet over a list?", "how do I make this
* follow the theme?" — in as few lines as it takes, so the answer can be read in one screen and
* copied without editing.</p>
*
* <p>That is the whole design constraint: <b>the boilerplate lives here, the substance lives in the
* subclass.</b> A recipe that spends twenty lines building a title bar teaches the title bar.</p>
*
* <p>Write one as:</p>
*
* <pre>
* public class MyRecipe extends Recipe
* {
* public String title() { return "What this shows"; }
* public String note() { return "The one sentence worth knowing."; }
*
* public void build(Component body) throws Exception
* {
* body.add(LAY_AUTO, new Button("Tap"));
* }
* }
* </pre>
*/
public abstract class Recipe extends SampleWindow implements Described
{
@Override
public abstract String title();
/**
* The one thing a reader should take away — printed above the recipe, in caption tone.
*
* @return the note, or null for none
*/
@Override
public String note()
{
return null;
}
/**
* Builds the recipe.
*
* @param body a vertical, scrollable container with sensible padding — add to it and stop
* @throws Exception when building fails
*/
public abstract void build(Component body) throws Exception;
@Override
public void onAdd(boolean firstTime) throws Exception
{
addTopBar(title());
// The page, not the surface. addTopBar just set fullscreenColor to the surface,
// and on a white page a white card has no edges — see Tones.page(). Re-derived on restyle so
// a dark-mode flip does not leave the light page behind.
fullscreenColor = Tones.page();
onRestyle(() -> fullscreenColor = Tones.page());
contents.setLayout(new VerticalLayout(Position.START, Position.START));
contents.getStyle()
.ingap(Gap.ofFont(1f, 1f, 1f, 1f));
String n = note();
if (n != null)
contents.add(LAY_AUTO, outgap(nota(n), 0, 0, 0, 1f));
Component body = new Component();
body.setLayout(new VerticalLayout(Position.START, Position.START));
body.setScrollable(ScrollDirection.VERTICAL);
body.testTag("recipe.body");
contents.add(LAY_FILL, body);
build(body);
if (pinned != null)
{
// Measured again once its labels have sizes: the band's AUTO height is taken in the first
// layout, before them, and on the iPhone it showed only its first line - the odometer's
// "No text recognizer on this device." was set and never seen (17/10/2026).
final Component band = pinned;
onUI(band::invalidateMeasure);
}
if (pinned != null)
body.add(LAY_AUTO, new Component()
{
@Override
public int getAutoHeight()
{
// Room for the last row to scroll clear of the floating band.
return pinned.getAutoHeight();
}
});
}
/**
* The note, as a tinted band rather than three lines of grey.
*
* <p>It is the one sentence the reader is meant to leave with, and it used to be the least
* legible thing on the screen: caption-coloured, caption-sized, indistinguishable from the
* captions further down that are only labels. A tone and a glyph cost nothing here — nobody has
* to read the band to know what it is.</p>
*
* @param n the note text
* @return the band
* @throws Exception when building fails
*/
/**
* Pins {@code c} to the bottom of the screen, in a band that FLOATS over the scrolling body: it
* stays on screen however far the body is scrolled, and the body scrolls underneath it. That is the
* place a recipe says what just happened. An answer at the top of the body went out of view as soon
* as the person scrolled down to the button that produced it (14/10/2026).
* <p>
* The band is 80% OPAQUE (the surface at alpha 0xCC), as the user asked; it floats rather than
* sitting below the body because a transparency with nothing behind it cannot be seen -- the first
* version had the band under the body, and the person saw a plain bar. The body gets a spacer as
* tall as the band at its end, so its last row can still scroll clear of it.
*
* @param c the component to pin, usually the answer label
* @return {@code c}, to chain
* @throws Exception if the layout refuses
*/
protected <T extends Component> T pin(T c) throws Exception
{
Component band = new Component();
band.setLayout(new VerticalLayout(Position.START, Position.START));
band.style(Skin.get()
.card()
.backColor(Styles.backColor.withAlpha(0xCC))
.borderColor(Styles.borderColor.withAlpha(0x33))
.borderRadius(0)
.shadow(0, 0, 0, 0)
.ingap(Gap.ofFont(1f, 1f, 1f, 1.2f))
.outgap(Gap.ofFont(0)));
band.add(LAY_AUTO, c);
// Floated: positioned at the bottom, full width, over whatever the body has there.
band.setRelativeBounds(Relative.START, Relative.END, Relative.FULL, Relative.AUTO);
contents.add(LAY_AUTO, band);
pinned = band;
return c;
}
/** The floating band {@link #pin} made, or null; the body ends in a spacer as tall as it. */
private Component pinned;
private Component nota(String n) throws Exception
{
Component band = new Component();
band.setLayout(new GridLayout(Position.START, Position.START));
band.style(Skin.get()
.card()
.backColor(Tones.accent().withAlpha(0x16))
.borderColor(Tones.accent().withAlpha(0x4A))
.shadow(0, 0, 0, 0)
.outgap(Gap.ofFont(0)));
// 0xE88E is the outlined "info". The glyph column hugs; the text takes the rest and wraps.
// The weights are GridLayout's own — 0f hugs, 1f takes what is left. LAY_FILL belongs to the
// vertical and horizontal layouts and means nothing here: it collapsed the text to nothing.
band.add(LAY_AUTO, 1f, matglyph(0xE88E, Tones.accent(), 1.05f),
outgap(label(n, 1f, false, secondary(), Position.START), .5f, 0, 0, 0));
return band;
}
}package dev.cf;
import dev.crossfly.ui.*;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.style.*;
import static dev.cf.sample.CrossFlySamplesColors.*;
/**
* Base class for all CrossFly sample windows.
* <p>
* Provides common infrastructure:
* <ul>
* <li>Shared color palette and styles from {@link dev.cf.sample.CrossFlySamplesColors}</li>
* <li>{@link #addTopBar(String, Button...)} — standard deep-indigo top bar with back button</li>
* <li>{@link #barButton(String, Runnable)} — helper to create icon buttons for the bar</li>
* </ul>
*
* <h3>Usage</h3>
* <pre>
* public class MySample extends SampleWindow {
* public void onAdd(boolean firstTime) throws Exception {
* if (!firstTime) return;
* initWindow();
* addTopBar("My Sample");
* // ... add content
* }
* }
* </pre>
*/
public abstract class SampleWindow extends Window
{
/** The main content area of the window, added by {@link #addTopBar(String, Button...)}, for subclasses to add their content into. */
protected Component contents;
// ─── Top bar ─────────────────────────────────────────────────────────────
/**
* Builds and adds a standard top bar with a back (←) button on the left,
* a centered title, and any optional extra buttons on the right.
*
* @param title the text shown in the bar
* @param rightButtons zero or more icon buttons placed aligned to the right
* @throws Exception if component construction fails
*/
protected void addTopBar(String title, Button... rightButtons) throws Exception
{
fullscreenColor = Styles.backColor.color();
setLayout(new VerticalLayout());
add(0f, buildTopBar(title, rightButtons));
add(LAY_FILL, contents = new Component());
contents.getStyle()
.ingap(Gap.ofFont(0, .5f, 0, .5f));
}
/**
* Same as {@link #addTopBar(String, Button...)}, but returns the bar component
* instead of adding it, for callers that need to position it manually.
*
* @param title the bar title
* @param rightButtons optional right-side icon buttons
* @return the assembled top-bar {@link Component}
* @throws Exception if component construction fails
*/
protected Component buildTopBar(String title, Button... rightButtons) throws Exception
{
Button btBack = barButton("\ue5c4", this::hide); // arrow_back
btBack.testTag("sample.back");
// Padded on every side, like the bar's right buttons: with none above and below, the target
// was the arrow itself, too small to hit on a small phone (iPhone 5s, 17/10/2026).
btBack.getStyle()
.ingap(Gap.ofFont(.75f))
.outgap(Gap.ofFont(0, 0, .25f, 0));
Component bar = new Component().style(barStyle);
bar.setLayout(new GridLayout(Position.START, Position.CENTER));
int n = 2 + rightButtons.length;
Object[] params = new Object[n * 2];
params[0] = 0f;
params[1] = 1f;
for (int i = 0; i < rightButtons.length; i++)
params[2 + i] = 0f;
params[n] = btBack;
params[n + 1] = new Label(title).style(barTitleStyle);
for (int i = 0; i < rightButtons.length; i++)
{
params[n + 2 + i] = rightButtons[i];
rightButtons[i].getStyle()
.ingap(Gap.ofFont(.75f));
}
bar.add(params);
return bar;
}
/**
* Creates a standard bar icon button (transparent, white icon, pressed feedback).
*
* @param materialIcon the Material Icon unicode codepoint string
* @param action the action to run when the button is tapped
* @return the configured {@link Button}
*/
protected Button barButton(String materialIcon, Runnable action)
{
return new Button(new MaterialIcon(materialIcon)).style(barButtonNormal)
.pressedStyle(barButtonPressed)
.onAction(e -> action.run());
}
}本页的机器可读版本,让模型直接读取示例集,而不是猜测 API。