Fusionner la phase scan camera (ZXing.Net)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -60,18 +60,54 @@ Le décodage EAN-13 se fait en **C# avec [ZXing.Net](https://www.nuget.org/packa
|
||||
|---|---|
|
||||
| Décodage EAN-13 propre (380×160) | 0,04 ms/frame |
|
||||
| **Pire cas** : frame 640×480 bruitée sans code-barres (échec) | 0,53 ms/frame |
|
||||
| Surcoût du payload PWA | +192 Ko (brotli) |
|
||||
| Poids de l'assembly `zxing.wasm` seul | +192 Ko (brotli) |
|
||||
|
||||
Le pire cas est le chiffre qui gouverne le framerate : la majorité des frames caméra ne contiennent pas de code-barres lisible, et c'est l'échec de décodage qui coûte le plus cher.
|
||||
|
||||
⚠️ Ces chiffres sont mesurés en **JIT x64 natif**. En Blazor WASM le code est *interprété* par défaut : compter un facteur ~10-20×, soit ~5-10 ms/frame — largement suffisant pour scanner à 10-15 fps. Activer `<RunAOTCompilation>true</RunAOTCompilation>` ramène ça à 1-2 ms, au prix d'un build nettement plus lent.
|
||||
### Mesures en WASM réel — faites le 2026-08-18, phase scan
|
||||
|
||||
L'estimation « ~5-10 ms/frame en interprété » ci-dessus n'était qu'une extrapolation. Elle a
|
||||
été **vérifiée dans un vrai navigateur** (Chromium 148, x86_64 de bureau), sur le publish
|
||||
`Release` du client, en appelant le décodeur depuis la console via `BancEssaiScan` :
|
||||
|
||||
| Configuration | Pire cas 640×288 (bande visée) | Pire cas 640×480 |
|
||||
|---|---|---|
|
||||
| Publish `Release`, interprété | **4,4 – 6,4 ms/frame** | 6,7 – 7,8 ms/frame |
|
||||
| Build `Debug`, interprété | 17,7 ms/frame | 28,7 ms/frame |
|
||||
|
||||
**L'estimation était bonne** : le mode interprété tient largement les 10-15 fps visés (une
|
||||
frame toutes les 80 ms n'utilise que ~6 % du budget). **Aucune raison d'activer l'AOT** —
|
||||
il reste à confirmer sur un téléphone, sensiblement plus lent qu'un x86_64 de bureau.
|
||||
|
||||
⚠️ Ne jamais juger la fluidité sur un build `Debug` : il est **4 à 5× plus lent** que le
|
||||
`Release`, de quoi conclure à tort qu'il faut l'AOT.
|
||||
|
||||
### ⚠️ Le surcoût de payload réel est bien supérieur à 192 Ko
|
||||
|
||||
Mesuré par différence entre deux publish `Release` complets (somme brotli de `_framework`) :
|
||||
**+351 Ko** au total, dont ~4,7 Ko de code applicatif. Le coût imputable à ZXing.Net est donc
|
||||
d'environ **+346 Ko brotli**, soit **1,8× le poids de son propre assembly**. Le surplus vient
|
||||
des assemblies BCL que le trimmer ne peut plus retirer :
|
||||
|
||||
| Assembly | Delta brotli |
|
||||
|---|---|
|
||||
| `zxing.wasm` | +192 495 o |
|
||||
| `System.Text.RegularExpressions` | +91 237 o (de 7 Ko à 98 Ko : ZXing utilise Regex, tout le moteur reste) |
|
||||
| `System.Runtime.Numerics` | +30 701 o (nouveau) |
|
||||
| `System.Private.CoreLib` | +17 586 o |
|
||||
| divers (`Threading`, `Collections`, `InteropServices`…) | ~14 Ko |
|
||||
|
||||
Ne pas reprendre « +192 Ko » comme coût du scan : c'est le poids de l'assembly, pas celui
|
||||
de la fonctionnalité.
|
||||
|
||||
### Pièges à connaître
|
||||
|
||||
- **Le JS interop ne disparaît pas.** `getUserMedia` et `<canvas>`/`getImageData` sont des API web sans équivalent C#. Prévoir ~30 lignes de JS maison dont le seul rôle est de pousser un `byte[]` vers C#. Toute la logique de décodage reste en C#.
|
||||
- **Ne pas perdre de temps à essayer de réduire la taille via un reader ciblé.** Remplacer `MultiFormatReader` par `EAN13Reader` pour aider le trimmer **ne change rien** : mesuré à 192 495 octets à l'octet près dans les deux cas. ZXing.Net n'est pas trim-friendly.
|
||||
- `RGBLuminanceSource` accepte directement le buffer RGBA du canvas (`BitmapFormat.RGBA32`) — **aucune bibliothèque d'image nécessaire** (pas de SkiaSharp ni ImageSharp).
|
||||
- Le scan caméra exige **HTTPS** (garanti par YunoHost en prod ; en dev, `localhost` est considéré comme sûr).
|
||||
- Le scan caméra exige **HTTPS** (garanti par YunoHost en prod ; en dev, `localhost` est considéré comme sûr). Corollaire vérifié : tester le scan depuis un téléphone en pointant l'IP locale du PC (`http://192.168.x.x`) **échouera toujours** — ce n'est pas un contexte sécurisé. Le composant détecte ce cas et le dit explicitement.
|
||||
- **Une `DOMException` perd son `name` en traversant le pont JS→C#** : `getUserMedia` refusé remonte en C# sous la forme « Permission denied undefined », sans `NotAllowedError`. Or c'est ce nom qui distingue « permission refusée » de « pas de caméra » de « caméra occupée ». Le JS doit donc **attraper l'erreur et renvoyer un code de statut** ; parser le message côté C# ne marche pas.
|
||||
- **L'import du module JS doit porter une chaîne de requête** (`./js/scanner-camera.js?m=1`). En développement, l'import map généré par Blazor réécrit le chemin vers un nom empreinté que l'API hôte ne sert pas (elle utilise `UseStaticFiles`, qui ignore les points d'entrée empreintés) : l'import part en 404 et le scan ne démarre jamais. La chaîne de requête empêche cette réécriture ; en publication rien n'est réécrit pour ce fichier, donc le comportement est identique.
|
||||
|
||||
### Squelette validé
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
@*
|
||||
Scan du code-barres ISBN par la caméra.
|
||||
|
||||
Le JS (wwwroot/js/scanner-camera.js) ne fait QUE fournir des pixels : tout le décodage
|
||||
est en C# via ZXing.Net (IsbnScanner), conformément à la décision actée dans CLAUDE.md.
|
||||
|
||||
On ne décode pas à chaque frame de la caméra : la cadence est volontairement limitée
|
||||
(voir IntervalleMs). 10-15 images/s suffisent largement pour scanner un livre tenu à la
|
||||
main, et cela ménage la batterie du téléphone.
|
||||
*@
|
||||
@using System.Diagnostics
|
||||
@using MaBibli.Client.Scan
|
||||
@using MaBibli.Shared.Isbn
|
||||
@implements IAsyncDisposable
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<div class="scanner">
|
||||
@if (_etat == Etat.Erreur)
|
||||
{
|
||||
<p class="message-erreur" role="alert">@_erreur</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="scanner-cadre">
|
||||
<video @ref="_video" class="scanner-video" muted playsinline autoplay></video>
|
||||
<div class="scanner-viseur" aria-hidden="true"></div>
|
||||
</div>
|
||||
|
||||
<p class="message-discret">
|
||||
@if (_etat == Etat.Demarrage)
|
||||
{
|
||||
<text>Ouverture de la caméra…</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>Visez le code-barres au dos du livre, bien à plat et bien éclairé.</text>
|
||||
}
|
||||
</p>
|
||||
|
||||
@if (_avertissementFormat is not null)
|
||||
{
|
||||
<p class="message-avertissement">@_avertissementFormat</p>
|
||||
}
|
||||
|
||||
@if (_framesDecodees > 0)
|
||||
{
|
||||
@* Mesure réelle en WASM interprété : CLAUDE.md ne dispose que d'une estimation
|
||||
(~5-10 ms/frame extrapolés du JIT x64). Cette ligne donne le chiffre constaté
|
||||
sur l'appareil de l'utilisateur, seul moyen de décider d'activer l'AOT. *@
|
||||
<p class="message-discret scanner-mesure">
|
||||
Décodage : @_msMoyen.ToString("0.0") ms/frame en moyenne (@_framesDecodees frames)
|
||||
</p>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-discret" @onclick="AnnulerAsync">Annuler</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>Appelé avec l'ISBN-13 dès qu'un code-barres exploitable est décodé.</summary>
|
||||
[Parameter] public EventCallback<string> OnCodeDetecte { get; set; }
|
||||
|
||||
/// <summary>Appelé quand l'utilisateur renonce au scan.</summary>
|
||||
[Parameter] public EventCallback OnAnnuler { get; set; }
|
||||
|
||||
private enum Etat { Demarrage, Actif, Erreur }
|
||||
|
||||
// Le « ?m=1 » n'est pas cosmétique. En développement, l'import map généré par Blazor
|
||||
// fait pointer « ./js/scanner-camera.js » vers un nom empreinté que l'API hôte ne sert
|
||||
// pas (elle utilise UseStaticFiles, qui ignore les points d'entrée empreintés) : l'import
|
||||
// échoue en 404 et le scan ne démarre jamais. Une chaîne de requête empêche l'import map
|
||||
// de réécrire le chemin ; en publication, aucune réécriture n'est générée pour ce fichier,
|
||||
// donc le comportement est identique. Vérifié dans les deux modes.
|
||||
private const string CheminModule = "./js/scanner-camera.js?m=1";
|
||||
|
||||
// ~12 images/s : bien assez pour scanner, et deux fois moins de travail que 25 fps.
|
||||
private const int IntervalleMs = 80;
|
||||
|
||||
// La frame est réduite avant transfert : un 1280×720 en RGBA pèse 3,6 Mo à traverser
|
||||
// le pont JS→C# à chaque itération. 640 px de large suffisent à lire un EAN-13.
|
||||
private const int LargeurMax = 640;
|
||||
|
||||
// Seule la bande centrale est décodée : c'est là que l'utilisateur vise.
|
||||
private const double PartHauteur = 0.45;
|
||||
|
||||
private ElementReference _video;
|
||||
private IJSObjectReference? _module;
|
||||
private CancellationTokenSource? _boucle;
|
||||
private Etat _etat = Etat.Demarrage;
|
||||
private string? _erreur;
|
||||
private string? _avertissementFormat;
|
||||
private long _framesDecodees;
|
||||
private double _totalMs;
|
||||
private double _msMoyen => _framesDecodees == 0 ? 0 : _totalMs / _framesDecodees;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool premierRendu)
|
||||
{
|
||||
if (!premierRendu)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_module = await JS.InvokeAsync<IJSObjectReference>("import", CheminModule);
|
||||
|
||||
var diagnostic = await _module.InvokeAsync<string>("diagnostic");
|
||||
if (diagnostic != "ok")
|
||||
{
|
||||
Echouer(diagnostic switch
|
||||
{
|
||||
// Piège garanti en développement : depuis un téléphone, pointer l'IP locale
|
||||
// du PC (http://192.168.x.x) n'est PAS un contexte sécurisé pour le navigateur.
|
||||
"contexte-non-securise" =>
|
||||
"La caméra n'est accessible qu'en HTTPS (ou sur localhost). "
|
||||
+ "En développement, ouvrir l'application par l'adresse IP du PC ne suffit pas. "
|
||||
+ "Saisissez l'ISBN à la main.",
|
||||
_ => "Ce navigateur ne donne pas accès à la caméra. Saisissez l'ISBN à la main.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// On ne laisse jamais un écran noir sans explication : chaque code dit précisément
|
||||
// ce qui manque à l'utilisateur pour que le scan fonctionne.
|
||||
var statut = await _module.InvokeAsync<string>("demarrer", _video);
|
||||
if (statut != "ok")
|
||||
{
|
||||
Echouer(statut switch
|
||||
{
|
||||
"permission-refusee" =>
|
||||
"L'accès à la caméra a été refusé. Autorisez-le dans les réglages du navigateur, "
|
||||
+ "ou saisissez l'ISBN à la main.",
|
||||
"aucune-camera" =>
|
||||
"Aucune caméra utilisable n'a été trouvée sur cet appareil. Saisissez l'ISBN à la main.",
|
||||
"camera-occupee" =>
|
||||
"La caméra est déjà utilisée par une autre application. Fermez-la, ou saisissez l'ISBN à la main.",
|
||||
_ => $"La caméra n'a pas pu démarrer ({statut[(statut.IndexOf(':') + 1)..].Trim()}). "
|
||||
+ "Saisissez l'ISBN à la main.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_etat = Etat.Actif;
|
||||
StateHasChanged();
|
||||
|
||||
_boucle = new CancellationTokenSource();
|
||||
_ = BoucleAsync(_boucle.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Echouer($"La caméra n'a pas pu démarrer ({ex.Message}). Saisissez l'ISBN à la main.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BoucleAsync(CancellationToken jeton)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!jeton.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(IntervalleMs, jeton);
|
||||
|
||||
if (_module is null || jeton.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var dimensions = await _module.InvokeAsync<int[]?>("capturer", LargeurMax, PartHauteur);
|
||||
if (dimensions is null)
|
||||
{
|
||||
continue; // vidéo pas encore prête
|
||||
}
|
||||
|
||||
var pixels = await _module.InvokeAsync<byte[]>("pixels");
|
||||
|
||||
var depart = Stopwatch.GetTimestamp();
|
||||
var code = IsbnScanner.TryDecode(pixels, dimensions[0], dimensions[1]);
|
||||
_totalMs += Stopwatch.GetElapsedTime(depart).TotalMilliseconds;
|
||||
_framesDecodees++;
|
||||
|
||||
if (code is null)
|
||||
{
|
||||
// Une frame sur dix suffit à rafraîchir la mesure sans repeindre en boucle.
|
||||
if (_framesDecodees % 10 == 0)
|
||||
{
|
||||
StateHasChanged();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var isbn = IsbnUtils.Normaliser(code);
|
||||
if (isbn is null || !IsbnUtils.EstIsbn13Valide(isbn))
|
||||
{
|
||||
// Un EAN-8, ou l'EAN d'un produit non-livre : on le dit et on continue.
|
||||
_avertissementFormat = $"Code lu (« {code} »), mais ce n'est pas un ISBN. Continuez à viser le code-barres du livre.";
|
||||
StateHasChanged();
|
||||
continue;
|
||||
}
|
||||
|
||||
await ArreterCameraAsync();
|
||||
await OnCodeDetecte.InvokeAsync(isbn);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Sortie normale : le composant a été quitté.
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// La page se ferme, plus rien à libérer côté JS.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AnnulerAsync()
|
||||
{
|
||||
await ArreterCameraAsync();
|
||||
await OnAnnuler.InvokeAsync();
|
||||
}
|
||||
|
||||
private void Echouer(string message)
|
||||
{
|
||||
_erreur = message;
|
||||
_etat = Etat.Erreur;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task ArreterCameraAsync()
|
||||
{
|
||||
_boucle?.Cancel();
|
||||
_boucle = null;
|
||||
|
||||
if (_module is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _module.InvokeVoidAsync("arreter");
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
// Quitter l'écran doit libérer la caméra, pas seulement arrêter la boucle.
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await ArreterCameraAsync();
|
||||
|
||||
if (_module is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _module.DisposeAsync();
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
|
||||
_module = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.8" PrivateAssets="all" />
|
||||
<!-- Décodage EAN-13 en C# (Apache 2.0). Le JS ne fournit que les pixels de la caméra. -->
|
||||
<PackageReference Include="ZXing.Net" Version="0.16.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
@if (_etape == Etape.Saisie)
|
||||
{
|
||||
<p class="message-discret">
|
||||
Saisissez l'ISBN imprimé sur le livre. Le scan par la caméra arrivera plus tard.
|
||||
Scannez le code-barres, ou saisissez l'ISBN imprimé sur le livre.
|
||||
</p>
|
||||
|
||||
<div class="barre-recherche">
|
||||
@@ -24,10 +24,20 @@
|
||||
@onclick="ChercherAsync">
|
||||
@(_chargement ? "Recherche…" : "Chercher")
|
||||
</button>
|
||||
@* La saisie manuelle reste le recours quand le code-barres est abîmé, absent,
|
||||
ou que la caméra est indisponible : elle ne disparaît jamais derrière le scan. *@
|
||||
<button type="button" class="bouton bouton-discret" @onclick="OuvrirScanner">
|
||||
Scanner
|
||||
</button>
|
||||
<a class="bouton bouton-discret" href="ajout/manuel">Saisir à la main</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_etape == Etape.Scan)
|
||||
{
|
||||
<ScannerCodeBarres OnCodeDetecte="SurCodeDetecteAsync" OnAnnuler="Recommencer" />
|
||||
}
|
||||
|
||||
@if (_erreur is not null)
|
||||
{
|
||||
<p class="message-erreur" role="alert">@_erreur</p>
|
||||
@@ -103,7 +113,7 @@
|
||||
}
|
||||
|
||||
@code {
|
||||
private enum Etape { Saisie, Choix, Formulaire }
|
||||
private enum Etape { Saisie, Scan, Choix, Formulaire }
|
||||
|
||||
private Etape _etape = Etape.Saisie;
|
||||
private string _isbn = string.Empty;
|
||||
@@ -115,6 +125,22 @@
|
||||
private IReadOnlyList<string> _avertissements = [];
|
||||
private EnregistrementLivre _saisie = new();
|
||||
|
||||
private void OuvrirScanner()
|
||||
{
|
||||
_erreur = null;
|
||||
_avertissements = [];
|
||||
_etape = Etape.Scan;
|
||||
}
|
||||
|
||||
// Le scan enchaîne directement sur le flux de lookup existant : l'utilisateur
|
||||
// ne retape jamais ce qui vient d'être scanné.
|
||||
private async Task SurCodeDetecteAsync(string isbn)
|
||||
{
|
||||
_isbn = isbn;
|
||||
_etape = Etape.Saisie;
|
||||
await ChercherAsync();
|
||||
}
|
||||
|
||||
private async Task SurTouche(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_isbn))
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace MaBibli.Client.Scan;
|
||||
|
||||
/// <summary>
|
||||
/// Mesure du coût réel du décodage <b>dans le navigateur</b>.
|
||||
///
|
||||
/// CLAUDE.md ne dispose que de chiffres JIT x64 (0,53 ms/frame dans le pire cas) et d'une
|
||||
/// extrapolation non vérifiée pour le WASM interprété (~5-10 ms/frame). Ce banc d'essai
|
||||
/// donne le chiffre constaté, sur la machine de l'utilisateur, sans caméra ni code-barres.
|
||||
///
|
||||
/// On mesure volontairement le <b>pire cas</b> — une frame de bruit sans code-barres —
|
||||
/// car c'est l'échec de décodage qui coûte le plus cher, et la quasi-totalité des frames
|
||||
/// d'une caméra sont dans ce cas. C'est donc lui qui gouverne le framerate atteignable.
|
||||
///
|
||||
/// Depuis la console du navigateur :
|
||||
/// <code>await DotNet.invokeMethodAsync('MaBibli.Client', 'MesurerScan', 640, 288, 20)</code>
|
||||
/// </summary>
|
||||
public static class BancEssaiScan
|
||||
{
|
||||
[JSInvokable("MesurerScan")]
|
||||
public static double MesurerPireCasMs(int largeur, int hauteur, int iterations)
|
||||
{
|
||||
var rgba = new byte[largeur * hauteur * 4];
|
||||
new Random(1234).NextBytes(rgba);
|
||||
|
||||
// Un tour à blanc : la première exécution paie l'initialisation de ZXing,
|
||||
// qui n'a rien à voir avec le coût par frame en régime établi.
|
||||
IsbnScanner.TryDecode(rgba, largeur, hauteur);
|
||||
|
||||
var depart = Stopwatch.GetTimestamp();
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
IsbnScanner.TryDecode(rgba, largeur, hauteur);
|
||||
}
|
||||
|
||||
return Stopwatch.GetElapsedTime(depart).TotalMilliseconds / iterations;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using ZXing;
|
||||
using ZXing.Common;
|
||||
|
||||
namespace MaBibli.Client.Scan;
|
||||
|
||||
/// <summary>
|
||||
/// Décodage d'un code-barres EAN-13 / EAN-8 à partir d'un buffer de pixels RGBA.
|
||||
///
|
||||
/// Décision actée dans CLAUDE.md : le décodage reste **entièrement en C#** (ZXing.Net),
|
||||
/// le JavaScript ne sert qu'à fournir les pixels de la caméra. Cette classe ne dépend
|
||||
/// donc ni du navigateur, ni de Blazor : elle est réutilisable telle quelle hors WASM
|
||||
/// (app native, décodage d'une photo côté serveur, scan en masse).
|
||||
/// </summary>
|
||||
public static class IsbnScanner
|
||||
{
|
||||
// ZXing.Net n'est pas thread-safe sur ce lecteur, mais le WASM est mono-thread
|
||||
// et la boucle de scan est séquentielle : une instance partagée suffit et évite
|
||||
// de reconstruire les hints à chaque frame.
|
||||
private static readonly MultiFormatReader Reader = new()
|
||||
{
|
||||
Hints = new Dictionary<DecodeHintType, object>
|
||||
{
|
||||
[DecodeHintType.POSSIBLE_FORMATS] = new List<BarcodeFormat>
|
||||
{
|
||||
BarcodeFormat.EAN_13, BarcodeFormat.EAN_8,
|
||||
},
|
||||
[DecodeHintType.TRY_HARDER] = true,
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Tente de décoder un code-barres. Renvoie le texte brut du code, ou <c>null</c>
|
||||
/// si la frame n'en contient aucun de lisible — cas très majoritaire en usage réel.
|
||||
/// </summary>
|
||||
/// <param name="rgba">Buffer brut issu de <c>ctx.getImageData(...).data</c> (4 octets par pixel).</param>
|
||||
public static string? TryDecode(byte[] rgba, int largeur, int hauteur)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rgba);
|
||||
|
||||
if (largeur <= 0 || hauteur <= 0 || rgba.Length < largeur * hauteur * 4)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// RGBLuminanceSource consomme directement le buffer du canvas :
|
||||
// aucune bibliothèque d'image nécessaire (ni SkiaSharp, ni ImageSharp).
|
||||
var source = new RGBLuminanceSource(
|
||||
rgba, largeur, hauteur, RGBLuminanceSource.BitmapFormat.RGBA32);
|
||||
|
||||
// La version .NET de ZXing renvoie null sur échec (pas d'exception).
|
||||
return Reader.decode(new BinaryBitmap(new HybridBinarizer(source)))?.Text;
|
||||
}
|
||||
}
|
||||
@@ -449,3 +449,36 @@ body {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Scan du code-barres --- */
|
||||
|
||||
.scanner-cadre {
|
||||
position: relative;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.scanner-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Le viseur matérialise la bande réellement décodée : viser ailleurs ne sert à rien. */
|
||||
.scanner-viseur {
|
||||
position: absolute;
|
||||
left: 5%;
|
||||
right: 5%;
|
||||
top: 27.5%;
|
||||
height: 45%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.85);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 0 100vmax rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.scanner-mesure {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Rôle unique de ce fichier : ouvrir la caméra et fournir des pixels à C#.
|
||||
// AUCUN décodage ici — il est fait par ZXing.Net côté C# (décision actée dans CLAUDE.md).
|
||||
// getUserMedia et canvas/getImageData n'ont pas d'équivalent C#, d'où ces quelques lignes.
|
||||
|
||||
let flux = null;
|
||||
let video = null;
|
||||
let canvas = null;
|
||||
let ctx = null;
|
||||
let derniersPixels = null;
|
||||
|
||||
// getUserMedia exige un contexte sécurisé : HTTPS, ou localhost.
|
||||
// Une IP de réseau local (192.168.x.x) en http NE l'est PAS — piège classique en dev.
|
||||
export function diagnostic() {
|
||||
if (!window.isSecureContext) return 'contexte-non-securise';
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return 'api-absente';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
// Renvoie un code de statut plutôt que de laisser remonter l'exception : une DOMException
|
||||
// traversée par le pont JS→C# perd son `name` (mesuré : C# ne reçoit que « Permission denied
|
||||
// undefined »), or c'est précisément ce nom qui dit à l'utilisateur ce qui lui manque.
|
||||
export async function demarrer(element) {
|
||||
video = element;
|
||||
try {
|
||||
// facingMode "environment" = caméra arrière : on scanne un livre tenu devant soi.
|
||||
// "ideal" et non "exact" pour ne pas échouer sur un PC qui n'a qu'une webcam frontale.
|
||||
flux = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } },
|
||||
audio: false,
|
||||
});
|
||||
} catch (e) {
|
||||
switch (e.name) {
|
||||
case 'NotAllowedError':
|
||||
case 'SecurityError':
|
||||
return 'permission-refusee';
|
||||
case 'NotFoundError':
|
||||
case 'DevicesNotFoundError':
|
||||
case 'OverconstrainedError':
|
||||
return 'aucune-camera';
|
||||
case 'NotReadableError':
|
||||
case 'TrackStartError':
|
||||
return 'camera-occupee';
|
||||
default:
|
||||
return 'erreur:' + (e.name || 'Error') + ' — ' + (e.message || '');
|
||||
}
|
||||
}
|
||||
video.srcObject = flux;
|
||||
video.setAttribute('playsinline', '');
|
||||
await video.play();
|
||||
canvas = document.createElement('canvas');
|
||||
ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
// Capture une frame et la garde en mémoire. Renvoie [largeur, hauteur], ou null si
|
||||
// la vidéo n'est pas encore prête. Les pixels se récupèrent ensuite via pixels().
|
||||
export function capturer(largeurMax, partHauteur) {
|
||||
if (!video || !ctx || video.readyState < 2 || !video.videoWidth) return null;
|
||||
const echelle = Math.min(1, largeurMax / video.videoWidth);
|
||||
const l = Math.round(video.videoWidth * echelle);
|
||||
const h = Math.round(video.videoHeight * echelle);
|
||||
if (canvas.width !== l || canvas.height !== h) { canvas.width = l; canvas.height = h; }
|
||||
ctx.drawImage(video, 0, 0, l, h);
|
||||
// On ne renvoie que la bande centrale, celle que l'utilisateur vise : moins de pixels
|
||||
// à transférer et à décoder, donc moins de travail par frame côté C#.
|
||||
const hb = Math.max(1, Math.round(h * partHauteur));
|
||||
const y = Math.round((h - hb) / 2);
|
||||
derniersPixels = new Uint8Array(ctx.getImageData(0, y, l, hb).data.buffer);
|
||||
return [l, hb];
|
||||
}
|
||||
|
||||
export function pixels() {
|
||||
return derniersPixels;
|
||||
}
|
||||
|
||||
// Libérer la caméra : sinon le voyant reste allumé et la batterie se vide,
|
||||
// ce que l'utilisateur perçoit — à raison — comme un dysfonctionnement.
|
||||
export function arreter() {
|
||||
if (flux) { flux.getTracks().forEach(t => t.stop()); flux = null; }
|
||||
if (video) { video.srcObject = null; video = null; }
|
||||
canvas = null; ctx = null; derniersPixels = null;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using MaBibli.Client.Scan;
|
||||
using ZXing.Common;
|
||||
using ZXing.OneD;
|
||||
|
||||
namespace MaBibli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests du décodage pur (aucune caméra, aucun navigateur) : on encode un EAN-13 avec
|
||||
/// ZXing, on le convertit en buffer RGBA comme le ferait <c>getImageData</c>, et on
|
||||
/// vérifie le trajet aller-retour. Aucune image externe n'est nécessaire.
|
||||
/// </summary>
|
||||
public class IsbnScannerTests
|
||||
{
|
||||
// Les ISBN de vérification du projet (voir CLAUDE.md).
|
||||
[Theory]
|
||||
[InlineData("9782070612758")] // Le Petit Prince
|
||||
[InlineData("9782253004226")] // Germinal
|
||||
[InlineData("9782080704092")] // Le Horla
|
||||
[InlineData("9780262033848")] // Introduction to Algorithms
|
||||
public void Decode_un_EAN13_genere(string isbn)
|
||||
{
|
||||
var (rgba, largeur, hauteur) = ImageEan13(isbn);
|
||||
|
||||
Assert.Equal(isbn, IsbnScanner.TryDecode(rgba, largeur, hauteur));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_aussi_un_EAN8()
|
||||
{
|
||||
// Pas un ISBN, mais le format est dans les hints : le décodeur doit le lire,
|
||||
// c'est la couche appelante qui écarte les codes non-ISBN.
|
||||
var matrice = new EAN8Writer().encode("96385074", ZXing.BarcodeFormat.EAN_8, 260, 160);
|
||||
var (rgba, largeur, hauteur) = VersRgba(matrice);
|
||||
|
||||
Assert.Equal("96385074", IsbnScanner.TryDecode(rgba, largeur, hauteur));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renvoie_null_sur_une_frame_sans_code_barres()
|
||||
{
|
||||
// Cas très majoritaire en usage réel : la caméra filme autre chose.
|
||||
// ZXing doit rendre null, jamais lever d'exception.
|
||||
const int largeur = 640, hauteur = 480;
|
||||
var rgba = new byte[largeur * hauteur * 4];
|
||||
var alea = new Random(1234);
|
||||
alea.NextBytes(rgba);
|
||||
|
||||
Assert.Null(IsbnScanner.TryDecode(rgba, largeur, hauteur));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renvoie_null_sur_une_image_uniforme()
|
||||
{
|
||||
var rgba = new byte[320 * 200 * 4];
|
||||
Array.Fill(rgba, (byte)255);
|
||||
|
||||
Assert.Null(IsbnScanner.TryDecode(rgba, 320, 200));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 100)]
|
||||
[InlineData(100, 0)]
|
||||
[InlineData(-5, 100)]
|
||||
public void Renvoie_null_sur_des_dimensions_absurdes(int largeur, int hauteur)
|
||||
=> Assert.Null(IsbnScanner.TryDecode(new byte[4000], largeur, hauteur));
|
||||
|
||||
[Fact]
|
||||
public void Renvoie_null_si_le_buffer_est_trop_court()
|
||||
{
|
||||
// Une frame tronquée ne doit pas faire tomber la boucle de scan.
|
||||
Assert.Null(IsbnScanner.TryDecode(new byte[100], 640, 480));
|
||||
}
|
||||
|
||||
private static (byte[] Rgba, int Largeur, int Hauteur) ImageEan13(string isbn)
|
||||
=> VersRgba(new EAN13Writer().encode(isbn, ZXing.BarcodeFormat.EAN_13, 380, 160));
|
||||
|
||||
/// <summary>
|
||||
/// Convertit une BitMatrix en buffer RGBA, exactement la forme que le canvas
|
||||
/// du navigateur fournit à C# (4 octets par pixel, opaque).
|
||||
/// </summary>
|
||||
private static (byte[] Rgba, int Largeur, int Hauteur) VersRgba(BitMatrix matrice)
|
||||
{
|
||||
var largeur = matrice.Width;
|
||||
var hauteur = matrice.Height;
|
||||
var rgba = new byte[largeur * hauteur * 4];
|
||||
|
||||
for (var y = 0; y < hauteur; y++)
|
||||
{
|
||||
for (var x = 0; x < largeur; x++)
|
||||
{
|
||||
var valeur = matrice[x, y] ? (byte)0 : (byte)255;
|
||||
var i = ((y * largeur) + x) * 4;
|
||||
rgba[i] = valeur;
|
||||
rgba[i + 1] = valeur;
|
||||
rgba[i + 2] = valeur;
|
||||
rgba[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
return (rgba, largeur, hauteur);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,9 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MaBibli.Api\MaBibli.Api.csproj" />
|
||||
<!-- Le décodeur de code-barres vit côté client, mais c'est du C# pur (aucune dépendance
|
||||
au navigateur) : il se teste comme n'importe quelle bibliothèque. -->
|
||||
<ProjectReference Include="..\MaBibli.Client\MaBibli.Client.csproj" />
|
||||
<ProjectReference Include="..\MaBibli.Shared\MaBibli.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user