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:
parent
12a2ee2d64
commit
6809dc6143
89 changed files with 17141 additions and 228 deletions
27
apps/app_seeds/lib/services/ocr/label_text_extractor.dart
Normal file
27
apps/app_seeds/lib/services/ocr/label_text_extractor.dart
Normal 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;
|
||||
}
|
||||
110
apps/app_seeds/lib/services/ocr/ocr_language.dart
Normal file
110
apps/app_seeds/lib/services/ocr/ocr_language.dart
Normal 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];
|
||||
}
|
||||
212
apps/app_seeds/lib/services/ocr/tesseract_label_extractor.dart
Normal file
212
apps/app_seeds/lib/services/ocr/tesseract_label_extractor.dart
Normal 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 1–2 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('&', '&')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
Loading…
Add table
Add a link
Reference in a new issue