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 |
|
| 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 |
|
| **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.
|
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
|
### 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#.
|
- **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.
|
- **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).
|
- `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é
|
### 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>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.8" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.8" PrivateAssets="all" />
|
<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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
@if (_etape == Etape.Saisie)
|
@if (_etape == Etape.Saisie)
|
||||||
{
|
{
|
||||||
<p class="message-discret">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div class="barre-recherche">
|
<div class="barre-recherche">
|
||||||
@@ -24,10 +24,20 @@
|
|||||||
@onclick="ChercherAsync">
|
@onclick="ChercherAsync">
|
||||||
@(_chargement ? "Recherche…" : "Chercher")
|
@(_chargement ? "Recherche…" : "Chercher")
|
||||||
</button>
|
</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>
|
<a class="bouton bouton-discret" href="ajout/manuel">Saisir à la main</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (_etape == Etape.Scan)
|
||||||
|
{
|
||||||
|
<ScannerCodeBarres OnCodeDetecte="SurCodeDetecteAsync" OnAnnuler="Recommencer" />
|
||||||
|
}
|
||||||
|
|
||||||
@if (_erreur is not null)
|
@if (_erreur is not null)
|
||||||
{
|
{
|
||||||
<p class="message-erreur" role="alert">@_erreur</p>
|
<p class="message-erreur" role="alert">@_erreur</p>
|
||||||
@@ -103,7 +113,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private enum Etape { Saisie, Choix, Formulaire }
|
private enum Etape { Saisie, Scan, Choix, Formulaire }
|
||||||
|
|
||||||
private Etape _etape = Etape.Saisie;
|
private Etape _etape = Etape.Saisie;
|
||||||
private string _isbn = string.Empty;
|
private string _isbn = string.Empty;
|
||||||
@@ -115,6 +125,22 @@
|
|||||||
private IReadOnlyList<string> _avertissements = [];
|
private IReadOnlyList<string> _avertissements = [];
|
||||||
private EnregistrementLivre _saisie = new();
|
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)
|
private async Task SurTouche(KeyboardEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_isbn))
|
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;
|
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>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\MaBibli.Api\MaBibli.Api.csproj" />
|
<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" />
|
<ProjectReference Include="..\MaBibli.Shared\MaBibli.Shared.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user