Fusionner la phase scan camera (ZXing.Net)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-18 02:24:01 +02:00
co-authored by Claude Opus 5
10 changed files with 649 additions and 5 deletions
+39 -3
View File
@@ -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;
}
}
}
+2
View File
@@ -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>
+28 -2
View File
@@ -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))
+40
View File
@@ -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;
}
}
+53
View File
@@ -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;
}
}
+33
View File
@@ -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;
}
+102
View File
@@ -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);
}
}
+3
View File
@@ -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>