feat(inventory): photo-first drafts + on-device OCR (digitization R2+R4)

Lower the bulk-digitization cliff with two more routes on top of the
already-landed CSV import and "save and add another":

- Photo-first drafts (capture now, catalogue later): burst-capture
  photos (camera or multi-gallery) into unnamed draft varieties, shown
  in a "to catalogue" tray, hidden from the main list until named.
  Adds Variety.isDraft (schema), addDraftVariety/watchDrafts/nameDraft,
  the triage sheet and the inventory banner.
- On-device OCR label suggestion (Tesseract, offline, no Google): a
  "Suggest name from photo" button in the naming dialog behind a
  LabelTextExtractor interface (Tesseract on Android/iOS, no-op
  elsewhere). Reads the largest print via hOCR bounding boxes, drops
  boilerplate/low-confidence noise, preprocesses (grayscale, contrast,
  upscale) and sweeps rotations (0-315 deg) so tilted packets still
  read. Bundles tessdata_fast eng+spa; validated on-device against real
  packets. The photo is written to a temp file deleted immediately in a
  finally block (the plugin needs a path) - a bounded, documented
  exception to no-plaintext-at-rest.

This commit also carries the co-developed schema evolution v5 to v8 that
shares these files (organic flag, species viability years, crop
calendar, lot provenance/abundance/preservation format, condition
checks) plus their exports/migrations and i18n.

Tests: CSV/draft/OCR unit + widget + migration green in isolation.
Note: the full widget suite currently hangs (>10 min) - under investigation.
This commit is contained in:
vjrj 2026-07-09 21:23:46 +02:00
parent 12a2ee2d64
commit 6809dc6143
89 changed files with 17141 additions and 228 deletions

View file

@ -0,0 +1,27 @@
import 'dart:typed_data';
/// Extracts a suggested variety name from a photo of its packet/label an
/// optional, best-effort enrichment for the draft-triage flow. Kept behind this
/// interface so the app degrades gracefully: platforms without an on-device OCR
/// engine (desktop, web) get [NoOpLabelTextExtractor], and the UI hides the
/// "suggest name" affordance when [isSupported] is false.
abstract class LabelTextExtractor {
/// Whether this build can actually run OCR (true only where a native engine
/// is wired). The UI uses it to decide whether to offer the suggestion.
bool get isSupported;
/// Returns a suggested name read from [photo], or null when nothing usable
/// was found or OCR is unavailable. Never throws failures yield null.
Future<String?> suggestLabel(Uint8List photo);
}
/// The fallback where no on-device OCR exists: always unsupported, always null.
class NoOpLabelTextExtractor implements LabelTextExtractor {
const NoOpLabelTextExtractor();
@override
bool get isSupported => false;
@override
Future<String?> suggestLabel(Uint8List photo) async => null;
}

View file

@ -0,0 +1,110 @@
/// Locale-aware selection of Tesseract OCR language packs.
///
/// Tane is international by design: the OCR must read seed packets in whatever
/// language the user runs the app, not a fixed `eng`/`spa`. This maps the app's
/// preferred locales to the traineddata packs we actually bundle and degrades
/// gracefully an unbundled locale simply falls back to the Latin baseline
/// instead of failing. As more packs are bundled (incl. RTL/CJK scripts) they
/// light up automatically with no code change here.
library;
import 'dart:convert';
const _trainedDataSuffix = '.traineddata';
/// ISO 639-1 (locale) Tesseract language code (mostly ISO 639-2/T). Covers the
/// scripts we plausibly bundle; extend as new packs are added. Kept
/// intentionally broad international first, not one region.
const Map<String, String> isoToTesseract = {
// Latin-script European
'en': 'eng',
'es': 'spa',
'ca': 'cat',
'gl': 'glg',
'eu': 'eus',
'pt': 'por',
'fr': 'fra',
'de': 'deu',
'it': 'ita',
'nl': 'nld',
'pl': 'pol',
'cs': 'ces',
'ro': 'ron',
'sv': 'swe',
'da': 'dan',
'fi': 'fin',
'tr': 'tur',
// Cyrillic / Greek
'ru': 'rus',
'uk': 'ukr',
'el': 'ell',
// RTL scripts
'ar': 'ara',
'he': 'heb',
'fa': 'fas',
'ur': 'urd',
// CJK & other non-Latin
'zh': 'chi_sim',
'ja': 'jpn',
'ko': 'kor',
'hi': 'hin',
'th': 'tha',
};
/// Parses the bundled OCR config (`assets/tessdata_config.json`) into the set of
/// available Tesseract language codes (basenames, e.g. `eng`, `spa`). The config
/// is the single source of truth for what is shipped, so this stays in sync with
/// the assets automatically.
Set<String> parseAvailableOcrLanguages(String configJson) {
final data = jsonDecode(configJson) as Map<String, dynamic>;
final files = (data['files'] as List?)?.cast<String>() ?? const [];
return files
.map(
(f) => f.endsWith(_trainedDataSuffix)
? f.substring(0, f.length - _trainedDataSuffix.length)
: f,
)
.where((code) => code.isNotEmpty)
.toSet();
}
/// Resolves the `+`-joined Tesseract language string for [preferredLocales]
/// (ordered most-preferred first; each an ISO 639-1 code or a fuller tag like
/// `es_ES`/`zh-Hans`), limited to the [available] bundled packs.
///
/// Rules, in order:
/// 1. Each preferred locale that maps to an available pack is included, in
/// order, de-duplicated.
/// 2. The Latin [baseline] (`eng`) is appended if bundled Latin text is
/// common on packets even in non-Latin locales, and it's a safe default.
/// 3. If nothing matched and the baseline is unavailable, fall back to every
/// available pack (sorted) so OCR still runs with whatever we shipped.
///
/// Returns an empty string only when [available] is empty.
String resolveOcrLanguages({
required List<String> preferredLocales,
required Set<String> available,
String baseline = 'eng',
}) {
final ordered = <String>[];
for (final locale in preferredLocales) {
final code = tesseractCodeForLocale(locale);
if (code != null && available.contains(code) && !ordered.contains(code)) {
ordered.add(code);
}
}
if (available.contains(baseline) && !ordered.contains(baseline)) {
ordered.add(baseline);
}
if (ordered.isEmpty) {
ordered.addAll(available.toList()..sort());
}
return ordered.join('+');
}
/// Maps a locale tag to its Tesseract code, or null if unmapped. Accepts full
/// tags (`es_ES`, `zh-Hans-CN`) and uses the primary language subtag.
String? tesseractCodeForLocale(String localeTag) {
final primary = localeTag.split(RegExp('[_-]')).first.toLowerCase();
return isoToTesseract[primary];
}

View file

@ -0,0 +1,212 @@
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter_tesseract_ocr/flutter_tesseract_ocr.dart';
import 'package:image/image.dart' as img;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'label_text_extractor.dart';
/// On-device OCR via Tesseract (offline, no cloud). Reads the **largest** text
/// on a packet/label photo the variety name is the biggest print, not the
/// longest line so boilerplate like "Organic Product" or "Herb Seeds" never
/// wins.
///
/// Security note: the plugin takes a file path, so the photo is written to a
/// private temp file and **deleted immediately** in `finally` it never lives
/// as plaintext at rest beyond the OCR call. The bundled traineddata the plugin
/// copies to app storage is public reference data, not user content.
class TesseractLabelExtractor implements LabelTextExtractor {
const TesseractLabelExtractor({
this.language = 'eng+spa',
this.rotations = const [0, 90, 180, 270, 45, 135, 225, 315],
});
/// Tesseract language(s), '+'-joined; needs matching bundled traineddata.
final String language;
/// Angles (degrees) to OCR the photo at, keeping the most confident read.
/// Tesseract only reads axis-aligned text, so this makes a packet shot
/// sideways, upside-down or ~45° tilted still work. More angles = slower
/// (one OCR pass each); trim to `[0, 90, 180, 270]` if latency matters.
final List<int> rotations;
@override
bool get isSupported => true;
@override
Future<String?> suggestLabel(Uint8List photo) async {
final base = _preprocess(photo);
if (base == null) return null;
File? temp;
try {
final dir = await getTemporaryDirectory();
temp = File(p.join(dir.path, 'ocr_${identityHashCode(photo)}.png'));
HocrLabel? best;
for (final angle in rotations) {
final image = angle == 0 ? base : img.copyRotate(base, angle: angle);
await temp.writeAsBytes(img.encodePng(image), flush: true);
// hOCR carries per-word bounding boxes + confidence, so we can rank by
// font size, drop low-confidence noise, and compare rotations.
final hocr = await FlutterTesseractOcr.extractHocr(
temp.path,
language: language,
args: {'preserve_interword_spaces': '1'},
);
final candidate = readHocrLabel(hocr);
if (candidate != null &&
(best == null || candidate.score > best.score)) {
best = candidate;
}
}
return best?.label;
} on Object {
return null;
} finally {
try {
if (temp != null && await temp.exists()) await temp.delete();
} on Object {
// Best effort: nothing to recover if cleanup fails.
}
}
}
}
/// Words in an hOCR document ignored as packet boilerplate (lower-cased,
/// accent-stripped compare). Kept deliberately small and generic.
const _boilerplate = <String>{
'organic', 'product', 'products', 'herb', 'herbs', 'seed', 'seeds',
'bio', 'net', 'weight', 'wt', 'www', 'com', 'gr', 'grs', 'grams',
'variety', 'quality',
};
/// Selects the variety name from Tesseract [hocr]: keep the words whose glyph
/// height is close to the tallest (the title print), drop boilerplate, and join
/// them in reading order (top-to-bottom, left-to-right). Returns null when
/// nothing usable remains. Pure unit-tested without a native engine.
/// A label read from one hOCR pass, with a [score] (sum of the kept words'
/// confidences) used to compare rotations the most confident wins.
class HocrLabel {
const HocrLabel(this.label, this.score);
final String label;
final int score;
}
/// Thin wrapper returning just the label (see [readHocrLabel]).
String? pickLabelFromHocr(String hocr, {int minConfidence = 20}) =>
readHocrLabel(hocr, minConfidence: minConfidence)?.label;
HocrLabel? readHocrLabel(String hocr, {int minConfidence = 20}) {
final wordRe = RegExp(
r"""class=['"]ocrx_word['"][^>]*\bbbox\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)(?:[^>]*?x_wconf\s+(\d+))?[^>]*>([^<]*)<""",
);
final words = <_Word>[];
for (final m in wordRe.allMatches(hocr)) {
final x0 = int.parse(m.group(1)!);
final y0 = int.parse(m.group(2)!);
final y1 = int.parse(m.group(4)!);
// Missing confidence treat as passing (some hOCR omits x_wconf).
final confidence = m.group(5) == null ? 100 : int.parse(m.group(5)!);
final text = _unescape(m.group(6)!).trim();
if (text.isEmpty) continue;
words.add(
_Word(x0: x0, y0: y0, height: y1 - y0, text: text, confidence: confidence),
);
}
if (words.isEmpty) return null;
final maxHeight = words.map((w) => w.height).reduce(math.max);
// Keep prominent words (>= 60% of the tallest) with 3 letters, above a low
// confidence floor, that are not boilerplate. The length rule kills 12 char
// OCR fragments (the "Re Ma" garbage) while keeping a plausibly-misread title
// word like "Sucumber" (which the user just edits to "Cucumber").
final kept = words
.where(
(w) =>
w.height >= 0.6 * maxHeight &&
w.confidence >= minConfidence &&
_letterCount(w.text) >= 3 &&
!_boilerplate.contains(_fold(w.text)),
)
.toList();
if (kept.isEmpty) return null;
// Reading order: group into rows by vertical position, then left-to-right.
final band = maxHeight * 0.6;
kept.sort((a, b) {
if ((a.y0 - b.y0).abs() > band) return a.y0.compareTo(b.y0);
return a.x0.compareTo(b.x0);
});
final chosen = kept.take(6).toList();
final label = _titleCase(_clean(chosen.map((w) => w.text).join(' ')));
if (label.isEmpty) return null;
final score = chosen.fold<int>(0, (sum, w) => sum + w.confidence);
return HocrLabel(label, score);
}
class _Word {
const _Word({
required this.x0,
required this.y0,
required this.height,
required this.text,
required this.confidence,
});
final int x0;
final int y0;
final int height;
final String text;
final int confidence;
}
/// Grayscale + contrast + upscale to help Tesseract on blurry/small photos.
/// Returns the decoded image (ready to rotate/encode), or null if it can't be
/// decoded.
img.Image? _preprocess(Uint8List bytes) {
final decoded = img.decodeImage(bytes);
if (decoded == null) return null;
var image = decoded;
// Upscale so glyphs are big enough for the LSTM engine.
if (image.width < 1200) {
image = img.copyResize(image, width: 1200);
}
image = img.grayscale(image);
return img.adjustColor(image, contrast: 1.4);
}
/// Case- and accent-folded token for boilerplate matching.
String _fold(String s) {
const from = 'áàäâãéèëêíìïîóòöôõúùüûñç';
const to = 'aaaaaeeeeiiiiooooouuuunc';
final lower = s.toLowerCase();
final buffer = StringBuffer();
for (final ch in lower.split('')) {
final i = from.indexOf(ch);
buffer.write(i == -1 ? ch : to[i]);
}
return buffer.toString().replaceAll(RegExp(r'[^a-z0-9]'), '');
}
int _letterCount(String s) =>
RegExp(r'\p{L}', unicode: true).allMatches(s).length;
String _clean(String s) => s
.replaceAll(RegExp(r"[^\p{L}\p{N}\s'-]", unicode: true), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
String _titleCase(String s) => s
.toLowerCase()
.split(' ')
.map((w) => w.isEmpty ? w : '${w[0].toUpperCase()}${w.substring(1)}')
.join(' ');
String _unescape(String s) => s
.replaceAll('&amp;', '&')
.replaceAll('&#39;', "'")
.replaceAll('&quot;', '"')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>');