Rhuba — mes recettes et ma liste de courses
Application Android (Kotlin / Jetpack Compose / Room) pour enregistrer ses recettes de cuisine et en tirer une liste de courses triée par rayon.
Fonctionnalités
- Nouvelle recette depuis une photo d’un livre de cuisine : le texte est reconnu sur l’appareil (ML Kit), photo du plat optionnelle. La page étant découpée en sections, la recette est analysée sans modèle, à l’instant où elle est créée : titre, nombre de personnes, temps, présentation, préparation et ingrédients structurés. La recette s’ouvre déjà remplie ; l’analyse par le modèle n’est plus qu’un complément, à la demande. Éprouvé sur quatre livres de mises en page très différentes, en français et en anglais, y compris un livre pour enfants aux titres en lettres cursives et des doubles pages photographiées en travers.
- Nouvelle recette depuis un site (Marmiton et tout site publiant des données schema.org/Recipe) : titre, nombre de personnes, ingrédients, étapes et photo. Le partage d’un lien vers Rhuba (« Partager → Rhuba ») ouvre directement l’import.
- Compléter par un modèle de langage sur l’appareil (MediaPipe LLM
Inference), sans compte ni clé, sans envoi de données. Sur une page de livre
bien reconnue l’analyse déterministe ne lui laisse rien à faire ; il reste
utile là où elle cale : page sans encadré de temps, recette en prose,
ligne abîmée par la reconnaissance (« 1 c. à s. » rendu « lcàs »). Le modèle
extrait le titre, le nombre de personnes, les temps de préparation et
de cuisson, et les ingrédients structurés (quantité, unité, rayon) ; ce que
le texte donnait déjà lui sert de repli, il ne peut donc rien effacer. Pour la
description et la préparation il ne réécrit rien : il indique les premiers
et derniers mots de chaque section, et le texte d’origine est recopié tel
quel entre ces bornes (
AnalysisFormat.sliceVerbatim, comparaison sans accents ni casse, repli heuristique si la borne est introuvable). Tout reste éditable. La progression est affichée au fil de la génération (chargement du modèle, lecture, ingrédients trouvés sur attendus, préparation) avec le temps écoulé ; la génération s’arrête dès que l’objet JSON est complet, et une réponse tronquée est réparée plutôt que rejetée. Au premier usage, le modèle par défaut (Qwen 2.5 1.5B, 1,6 Go, empreinte SHA-256 vérifiée) est téléchargé automatiquement. Les réglages permettent d’en choisir un autre (Gemma 3 1B en 555 Mo, Gemma officiel avec jeton Hugging Face, adresse libre ou fichier.task) et d’activer le GPU (à réserver aux modèles légers : il double l’empreinte mémoire). Un parseur heuristique (data/parse/) redécoupe la sortie du modèle et sert aussi à l’import web. - Traduction automatique en français d’une recette écrite dans une autre
langue (livre anglais, site italien…), sur l’appareil (ML Kit Translation) :
la langue est reconnue, puis le titre, la présentation, les étapes et les
noms d’ingrédients sont traduits ; quantités, unités, temps et rayons ne
changent pas. On traduit le résultat de l’analyse, pas la page : le parseur
sait lire l’anglais, pas une traduction automatique. Les lignes coupées
par la colonne sont recollées en phrases avant traduction, et un petit
glossaire (
RecipeTranslation) évite les contresens culinaires du traducteur (« ground cumin » → « cumin au sol », « bay leaves » → « feuilles de baie »). Le texte d’origine reste dans « Texte de la recette ». Le modèle de traduction (une trentaine de Mo par langue) est téléchargé au premier usage ; sans réseau, la recette reste dans sa langue. Désactivable dans les réglages. - Recherche par aliment ou par titre.
- Saison : fruits et légumes de saison par mois, et les recettes qui les utilisent.
- Liste de courses composée depuis une ou plusieurs recettes avec choix du nombre de personnes (quantités ajustées et fusionnées), triée par rayon (fruits & légumes, viande, poisson, crèmerie, épicerie salée/sucrée, boulangerie, surgelés, boissons, autre). Ajout manuel d’articles, cases à cocher, retrait des articles cochés, vidage de la liste.
- Partage entre utilisateurs d’une recette ou de la liste de courses, par
le menu « Partager » d’Android (WhatsApp, Messenger, courriel, Dropbox,
Drive…), sans serveur ni compte. On envoie un fichier
.rhuba(un zip :rhuba.jsonet les photos), accompagné du même contenu en texte, lisible sans l’appli. Le destinataire touche le fichier et choisit Rhuba, ou passe par « Nouvelle recette → Choisir un fichier ». Chaque recette porte un identifiant (uid) qui voyage avec elle : une recette qu’on a déjà est reconnue, soit identique (rien à faire), soit dans une autre version (différences résumées, dates de modification, « Remplacer ma version » ou « Garder les deux »). Une liste de courses reçue s’ajoute à la sienne (quantités fusionnées) ou la remplace, et la même liste ouverte deux fois est reconnue. Le fichier porte un numéro de format : une version plus ancienne de l’appli refuse un fichier trop récent au lieu de le lire de travers. - Synchronisation entre deux téléphones (icône en haut de la liste des
recettes) : l’un crée un lien valable 10 minutes (
rhuba://sync/7-mot-mot) et l’envoie par une messagerie ; l’autre l’ouvre, ou colle le message entier si la messagerie ne rend pas le lien cliquable. Chacun envoie tout son carnet, photos comprises, et fusionne celui de l’autre : une recette inconnue est ajoutée, une recette connue (mêmeuid) prend la version la plus récente. Rien n’est supprimé : une recette effacée d’un seul côté revient de l’autre. La liste de courses n’est pas synchronisée. Transport par Magic Wormhole (crate Rustmagic-wormhole, dansnative/) : le code court sert de mot de passe (PAKE), les serveurs publics de rendez-vous et de relais ne voient que des données chiffrées, et un code ne sert qu’une fois. Connexion directe sur un même réseau, par le relais sinon. La crate est sous la même licence EUPL-1.2 que Rhuba.
Licence
Rhuba est un logiciel libre, sous licence publique de l’Union européenne
EUPL-1.2 (LICENSE ; version française officielle sur
https://interoperable-europe.ec.europa.eu/collection/eupl/eupl-text-eupl-12).
Les licences des composants de l’APK sont listées dans l’appli (Réglages ›
Licences) et commentées dans LICENCES.md.
Environnement de développement (NixOS)
Le shell reprend l’approche de coturnix/acc/app-android : un
buildFHSEnv avec JDK 17, Gradle, le SDK Android du store Nix et un patch
patchelf d’aapt2.
nix-shell # shell interactif (écrit local.properties)
./gradlew assembleDebug # APK debug → app/build/outputs/apk/debug/
./gradlew testDebugUnitTest # tests unitaires (parseur, rayons, saisons)
./gradlew installDebug # sur un émulateur/appareil connecté
Sans shell interactif : ./fhs.sh ./gradlew assembleDebug.
La bibliothèque de synchronisation (native/, Rust) est compilée par
cargo ndk depuis Gradle (tâches cargoNdk et uniffiBindings, refaites
seulement si native/ change) pour l’émulateur (x86_64) et les téléphones
(arm64), avec ses bindings Kotlin générés par UniFFI (uniffi.rhuba_sync,
appelés via JNA). Pour l’essayer sans second téléphone, un client en ligne de
commande échange un fichier avec l’appli :
cd native
cargo run --example sync -- join 7-mot-mot envoi.zip recu.zip # code affiché par l'appli
cargo run --example sync -- open envoi.zip recu.zip # puis ouvrir rhuba://sync/<code>
envoi.zip est un carnet au format de LibraryFormat (rhuba.json avec
"kind": "library", photos sous photos/).
Émulateur (SDK autonome dans ~/Android/Sdk, patché par le shell). L’AVD
Rhuba_API_36 (Android 36.1, Google Play, partition de données de 12 Go) a été
créé pour ce projet dans ~/.android/avd/ :
emulator -avd Rhuba_API_36 &
adb logcat -s "Rhuba:D"
Build hermétique
android.nix produit un APK et un AAB release hors ligne dans le sandbox
Nix (cache Gradle en dérivation à sortie fixe, puis dépôt Maven local, la
bibliothèque de synchronisation étant construite à part, crates vendorisées
depuis native/Cargo.lock).
# (après un changement de dépendances : relancer avec --argstr depsHash "" pour
# obtenir le nouveau hash, puis le reporter dans android.nix)
nix-build android.nix
ls result/ # rhuba.apk rhuba.aab
Signature : passer --arg keystoreFile /chemin/vers/upload.jks --argstr keystorePassword … --argstr keyAlias … --argstr keyPassword ….
Architecture
app/src/main/kotlin/fr/rhuba/app/
├── RhubaApp.kt conteneur de dépendances (DB, dépôts, réglages)
├── MainActivity.kt Compose + réception des liens partagés
├── data/
│ ├── db/ Room : recettes, ingrédients, articles de courses
│ ├── model/ types de domaine (ProductCategory, ParsedIngredient…)
│ ├── parse/ parseur d'ingrédients, unités, rayons, normalisation
│ ├── importer/ OCR (ML Kit) et import web (JSON-LD via Jsoup)
│ ├── ai/ LocalRecipeAnalyzer (MediaPipe), LocalModelStore (téléchargement),
│ │ AnalysisFormat (consigne, schéma, lecture de la réponse)
│ ├── season/ calendrier des fruits et légumes de saison
│ ├── settings/ clé API et modèle (EncryptedSharedPreferences)
│ ├── share/ fichiers .rhuba, carnet entier (LibraryFormat), comparaison
│ ├── licenses/ écran Licences : listes générées (crates, bibliothèques Android)
│ ├── sync/ synchronisation : RecipeSync, SyncService (premier plan), SyncCode
│ └── repo/ RecipeRepository, ShoppingRepository, ImageStore
native/ bibliothèque Rust de synchronisation (Magic Wormhole, UniFFI)
├── archive/ code retiré (analyse Claude / Mistral), non compilé
├── viewmodel/ un ViewModel par écran
└── ui/ thème, navigation (3 onglets) et écrans Compose
Le classement par rayon et la saisonnalité reposent sur des listes de
mots-clés en français (Categorizer.kt, SeasonalProduce.kt) : les enrichir
est le moyen le plus simple d’améliorer les résultats hors IA. Un mot-clé doit
désigner le produit et non sa découpe : le nom lu sur une page de livre traîne
la sienne (« cavolo nero, les tiges retirées, et les feuilles grossièrement
hachées »), et un « haché » isolé y rangeait le chou au rayon boucherie.
Réseau
Le client HTTP commun (data/net/RhubaHttp.kt) essaie les adresses IPv4 avant
les IPv6 avec un délai de connexion de 10 s : sur un réseau domestique qui
annonce une IPv6 non routée, un client naïf reste bloqué plusieurs minutes par
adresse avant de retomber en IPv4. Il sert au téléchargement des modèles et à
l’import web.
La synchronisation fait de même pour les serveurs de Magic Wormhole, qui ont
des adresses IPv6 : le serveur de rendez-vous est joint par la première adresse
qui répond, IPv4 d’abord, et le relais est annoncé adresse par adresse pour que
toutes soient essayées en même temps.
Base de données
Room, version 2. La migration 1 → 2 ajoute description, prepTimeMinutes et
cookTimeMinutes à la table recipes (RhubaDatabase.MIGRATION_1_2). Les
schémas exportés sont dans app/schemas/.
Reconnaissance de texte
La page est découpée en sections avant tout traitement : sur une page de livre à deux colonnes, la reconnaissance entrelace sinon la préparation et les ingrédients, et le modèle comme le parseur mélangent les deux. Le découpage se fait sur la géométrie rendue par ML Kit, pas sur les pixels : ML Kit donne déjà la position de chaque ligne, recadrer la photo pour la relire n’apprendrait rien de plus.
-
Redressement (
OcrLines,data/importer/OcrLayout.kt) — on travaille à la ligne et non au bloc (deux colonnes qui se touchent finissent parfois dans un même bloc), et chaque ligne est ramenée à une boîte de l’épaisseur du texte, centrée sur elle. Une photo de livre est toujours de travers, et la page est courbée : sur les photos d’essai l’inclinaison passe de -6° en haut de page à -1° en bas, aucune rotation d’ensemble ne la redresse. Or la boîte englobante d’une ligne penchée de 6° sur 2 500 pixels est étirée de 260 pixels en hauteur : elle chevauche ses voisines, et les bandes blanches qui séparent paragraphes et colonnes disparaissent. Seul le quart de tour fait exception : un livre ouvert en grand se photographie volontiers en travers, et ML Kit en lit alors toutes les lignes à la verticale (prises jusqu’ici pour des onglets de marge, et jetées). Le sens dominant du texte est reconnu (OcrLines.rotationOf), l’image remise d’aplomb et relue (OcrService.quads) : la seconde lecture est nettement meilleure que la géométrie tournée — les lettres cursives et les lignes voisines s’y mêlent moins — et ne coûte que sur ces photos-là. -
Découpage (
OcrLayout) — « XY-cut » récursif : on coupe à la plus large bande blanche traversante, verticale d’abord (la gouttière entre colonnes), horizontale à défaut (l’espace entre paragraphes), et on recommence. Les zones sortent dans l’ordre de lecture. La gouttière se mesure à la zone en cours de découpe (1,5 % de sa largeur, 0,6 hauteur de ligne au moins) : un livre pour enfants imprimé gros serre ses trois colonnes à 0,7 ligne, et une double page n’écarte pas ses colonnes du double de sa largeur. Dans une zone, les morceaux d’une même ligne imprimée sont recollés bout à bout : sur une page courbée, ML Kit rend « mérite d’être tentée », « de faire une expérience » et « de chimie », chacun plus bas que le précédent, et le dernier finissait rangé sous la ligne suivante. -
Étiquetage (
OcrSections) — chaque zone reçoit une section. Aucun indice n’est fiable seul, mais ensemble ils le sont : un titre est imprimé nettement plus gros que le texte ; une liste d’ingrédients aligne des lignes courtes commençant par une quantité ; des étapes contiennent des impératifs (« Mélangez », « Mets la levure », « Heat the oil ») ou sont numérotées ; l’encadré des temps tient en quelques mots qu’ouvrent « POUR », « PRÉPARATION », « CUISSON » — pas un « cuisson » au milieu d’une phrase. Le verdict se prend par zone entière, ce qui résiste aux lignes ambiguës, et des rattrapages traitent les cas où la géométrie a coupé trop fin : l’encadré des temps, réduit en miettes par sa grille de pictogrammes, est reconstitué de proche en proche (sans avaler les ingrédients voisins) ; une ligne isolée entre deux zones de même nature en fait partie ; les lignes muettes d’une liste (« Sel, poivre », « HOUMOUS », « To serve ») la rejoignent quand elles s’alignent à gauche sur elle.Le reste tient à la page entière. Le titre est le plus gros texte, où qu’il soit — sauf s’il surmonte, sans rien entre les deux qu’une illustration, un titre à peine plus petit : c’est un titre de chapitre (« Le riz », puis « Risotto »). Les rubriques imprimées en petit en haut de page (« Plat », « INFUSION ») sont écartées. Une page qui numérote ses étapes commence sa préparation à « 1. » : ce qui précède est la présentation, même quand elle conseille (« réduisez le temps de cuisson »). Les encadrés « Astuce », « Le conseil du chef », « Idée gourmande » ont leur section (
## ASTUCE), rendue à la suite des étapes. Et une page qui porte deux recettes (une recette de base, puis celle qui s’en sert) est partagée entre ses deux titres de même taille : on garde la plus fournie, l’autre reste dans le texte reconnu.
La reconnaissance lit la photo d’origine, pas la copie réduite qu’on conserve pour l’affichage (1600 pixels). Mesuré sur les trois pages d’essai : à 1600 pixels « 10 cl » se lit « 10l », « ½ cuil. à café d’oignon » n’est plus analysable et le nombre de couverts de la fiche disparaît ; à 3200 pixels tout cela revient, pour 2 044 ms par page contre 1 993 ms — la résolution ne coûte presque rien, ML Kit étant dominé par son modèle et non par la taille de l’entrée. La borne à 3200 pixels reste nécessaire : une photo de 100 mégapixels décompressée ne tiendrait pas en mémoire (ici 30 Mo au plus).
Le texte rendu porte ses sections en clair (## INGRÉDIENTS,
## PRÉPARATION…, cf. data/parse/RecipeSections.kt) : il reste une simple
chaîne, affichée et corrigeable par l’utilisateur, mais le parseur et le
modèle savent où chercher. Le modèle est prévenu du découpage, la recopie
verbatim de la description et de la préparation est bornée à leur section, la
liste d’ingrédients est lue dans la sienne, et les temps comme le nombre de
personnes sont lus dans l’encadré (« POUR » suivi du seul nombre de couverts).
Un texte sans marqueur — saisi à la main, ou une recette d’avant — est traité
comme auparavant.
La liste d’ingrédients est ensuite extraite par le parseur déterministe
(IngredientParser), qui reconnaît aussi bien un en-tête « Ingrédients » que
les sections « Pour la pâte : » des livres, et recolle les ingrédients coupés
sur plusieurs lignes. Une colonne étroite en coupe certains en trois — « 20 g
de coriandre fraîche, / les feuilles retirées, et les tiges / finement
hachées » : la longueur qui distingue un ingrédient d’une phrase se mesure
donc sur la ligne imprimée, jamais sur le groupe recollé, qu’une étape dépasse
dès sa première ligne. Sur une liste structurée il bat nettement un modèle de
1,5 milliard de paramètres : sa liste remplace celle du modèle dès qu’elle est
plus complète. Les temps sont lus sous les étiquettes « PRÉPARATION » et
« CUISSON » des fiches (« CUISSON : 10 MINUTES + 20 À 30 MINUTES » font 30,
la borne basse d’une fourchette comme pour les quantités). Le modèle garde le
titre, le nombre de personnes et les bornes de la description.
Le parseur connaît aussi les faiblesses de la reconnaissance et des mises en page. Une ligne sans quantité est la suite de l’ingrédient du dessus — sauf quand elle commence par un produit, en minuscules, après un ingrédient complet (« origan », « sucre en poudre » d’un livre pour enfants ; la « courge de 1 kg » dont le « 1 » s’est perdu) ; une parenthèse ouverte, un mot de liaison en fin de ligne (« au moulin à », « cut into ») ou un « + » en tête prolongent l’ingrédient. Le « 1 » lu « l » ou « I », la fraction lue « % », le « g » lu « q », l’« œ » lu « ce » sont réparés en tête de ligne. Un livre en anglais donne « 200 g/7 oz (2 cups…) gram flour » : la mesure métrique est gardée, l’équivalence impériale écartée, et les unités anglaises (« tablespoon », « cloves », « sticks »…) comme les rayons ont leur vocabulaire. Enfin le titre imprimé en capitales est ramené en casse normale, et son esperluette ornée, lue « 8 », redevient un « & ».
Tests
Vingt et une vraies pages de livre servent de non-régression, avec la
géométrie que ML Kit leur donne (app/src/test/resources/ocr/*.txt). Les
quatre premières, rejouées par OcrSectionsTest : deux colonnes avec les
ingrédients à droite, les mêmes avec un titre pleine largeur, une page courbée
où les ingrédients sont à gauche, et une page sans encadré de temps dont la
colonne d’ingrédients est si étroite que chacun tient sur trois lignes. Les
dix-sept suivantes, rejouées par RecipePagesTest, viennent de quatre livres :
fiche en toutes lettres et encadré « ASTUCE » ; livre pour enfants aux titres
cursifs, trois colonnes serrées et doubles pages en travers ; livre en
anglais ; pages à longue présentation, étapes numérotées, et deux recettes sur
une page. Toute l’analyse se rejoue ainsi en JVM, en quelques secondes, sans
émulateur.
Pour comprendre pourquoi une page est mal lue, OcrDiagnostic écrit dans
app/build/diag/ le résultat de chaque gabarit, son texte découpé et ses
zones avec leur boîte :
RHUBA_DIAG=1 ./fhs.sh ./gradlew testDebugUnitTest --tests '*OcrDiagnostic'
Ces gabarits sont produits sur appareil par OcrFixtureDump
(app/src/androidTest/), qui rejoue la reconnaissance sur les photos déposées
dans app/src/androidTest/assets/pages/ — ML Kit ne tourne que sur un
appareil, la géométrie qu’il rend se rejoue partout. La photo y est décodée par
ImageStore.decodeForOcr, comme dans l’application : orientée et bornée à
3200 pixels. C’est cette image-là que la reconnaissance voit en vrai — un
gabarit pris autrement testerait autre chose que le produit. Ces photos ne sont pas
suivies (elles pèsent cent fois le reste du dépôt) : il faut les y remettre,
sous le même nom, pour régénérer les gabarits. La marche à suivre est en tête
du fichier OcrFixtureDump.kt. RecipeFromPhotoTest, sur appareil lui aussi,
rejoue la chaîne entière — photo, reconnaissance, découpage, enregistrement —
et vérifie qu’une recette photographiée arrive analysée sans qu’aucun modèle
n’ait été chargé, qu’elle soit photographiée droite ou en travers. Un dernier
test rejoue le texte à plat
d’une page, d’avant le découpage : app/src/test/resources/ocr-steak-chou-fleur.txt.
Diagnostic
L’analyse écrit une ligne de bilan dans le journal système :
adb logcat -s "Rhuba:D"
# Rhuba: analyse 49 s (CPU, 1149 car.) → 6 ingr., 6 pers., prépa -, cuisson -, description oui
Détails d’interface
- Ajouter un ingrédient place le curseur dans le champ créé.
- Les photos d’une recette (plat et page du livre) s’ouvrent en plein écran et
défilent horizontalement (
ui/components/PhotoViewer.kt). - La liste des recettes affiche le nombre de personnes et le temps de préparation ; la fiche les reprend en pastilles avec le temps de cuisson.