283 lines
12 KiB
C#
283 lines
12 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
using System.Runtime.InteropServices;
|
|
using System.Threading.Tasks;
|
|
using OpenCvSharp;
|
|
using ZXing;
|
|
using ZXing.Common;
|
|
using ZXing.Multi;
|
|
using ZXing.QrCode;
|
|
|
|
namespace BarcodeReaderExample
|
|
{
|
|
class Program
|
|
{
|
|
// ---- tunables -------------------------------------------------
|
|
const bool EnableOverlay = true; // master draw switch (debug)
|
|
const bool TryHarderMultiDecode = true; // more reliable multi-QR detection, costs some speed
|
|
const float QuadExpansion = 1.60f; // how much bigger than the QR the overlay is drawn
|
|
const int MaxMissedFrames = 8; // keep last known position this many frames if briefly lost
|
|
const int TargetFps = 60;
|
|
|
|
static readonly HttpClient Http = new HttpClient();
|
|
static readonly ConcurrentDictionary<string, Mat> ImageCache = new(); // text -> decoded image (null = failed)
|
|
static readonly ConcurrentDictionary<string, byte> Pending = new(); // text currently downloading
|
|
|
|
static readonly QRCodeReader QrReader = new QRCodeReader();
|
|
static readonly GenericMultipleBarcodeReader MultiReader = new GenericMultipleBarcodeReader(QrReader);
|
|
static readonly Dictionary<DecodeHintType, object> Hints = new()
|
|
{
|
|
{ DecodeHintType.POSSIBLE_FORMATS, new List<BarcodeFormat> { BarcodeFormat.QR_CODE } },
|
|
{ DecodeHintType.TRY_HARDER, TryHarderMultiDecode },
|
|
};
|
|
|
|
static byte[] _grayBuffer;
|
|
static readonly Dictionary<string, TrackedQr> Tracked = new();
|
|
|
|
class TrackedQr
|
|
{
|
|
public Point2f[] Quad; // TL, TR, BR, BL (frame coordinates)
|
|
public int MissedFrames;
|
|
}
|
|
|
|
static void Main(string[] args)
|
|
{
|
|
using VideoCapture camera = new VideoCapture("/dev/video0", VideoCaptureAPIs.V4L2);
|
|
if (!camera.IsOpened())
|
|
throw new Exception("Nie można otworzyć kamery /dev/video0");
|
|
|
|
camera.Set(VideoCaptureProperties.FrameWidth, 1280);
|
|
camera.Set(VideoCaptureProperties.FrameHeight, 720);
|
|
camera.Set(VideoCaptureProperties.Fps, TargetFps);
|
|
|
|
Cv2.NamedWindow("Camera preview", WindowFlags.AutoSize);
|
|
|
|
using var frame = new Mat();
|
|
using var gray = new Mat();
|
|
var sw = Stopwatch.StartNew();
|
|
var frameTimer = Stopwatch.StartNew();
|
|
int frameCount = 0;
|
|
double fps = 0;
|
|
|
|
try
|
|
{
|
|
while (true)
|
|
{
|
|
frameTimer.Restart();
|
|
|
|
if (!camera.Read(frame) || frame.Empty())
|
|
continue;
|
|
|
|
try
|
|
{
|
|
ProcessFrame(frame, gray);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Never let a single bad frame kill the capture loop.
|
|
Console.WriteLine("Frame processing error: " + ex.Message);
|
|
}
|
|
|
|
frameCount++;
|
|
if (sw.ElapsedMilliseconds >= 500)
|
|
{
|
|
fps = frameCount / (sw.ElapsedMilliseconds / 1000.0);
|
|
frameCount = 0;
|
|
sw.Restart();
|
|
}
|
|
Cv2.PutText(frame, $"{fps:0.0} fps", new Point(10, frame.Height - 15),
|
|
HersheyFonts.HersheySimplex, 0.6, Scalar.Lime, 2);
|
|
|
|
Cv2.ImShow("Camera preview", frame);
|
|
|
|
// Only sleep for whatever time is left in the frame budget instead of
|
|
// always sleeping the full 1000/fps on top of WaitKey (which halved
|
|
// the effective frame rate in the original code).
|
|
int budgetMs = 1000 / TargetFps;
|
|
int elapsedMs = (int)frameTimer.ElapsedMilliseconds;
|
|
int waitMs = Math.Max(1, budgetMs - elapsedMs);
|
|
|
|
int key = Cv2.WaitKey(waitMs);
|
|
if (key == 27 || key == 'q')
|
|
break;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
foreach (var mat in ImageCache.Values)
|
|
mat?.Dispose();
|
|
Cv2.DestroyAllWindows();
|
|
}
|
|
}
|
|
|
|
static void ProcessFrame(Mat frame, Mat gray)
|
|
{
|
|
Cv2.CvtColor(frame, gray, ColorConversionCodes.BGR2GRAY);
|
|
Result[] results = ReadQrCodes(gray);
|
|
|
|
var seenThisFrame = new HashSet<string>();
|
|
|
|
foreach (var result in results)
|
|
{
|
|
if (result?.Text == null) continue;
|
|
var points = result.ResultPoints;
|
|
if (points == null || points.Length < 3) continue;
|
|
|
|
// ZXing QR point order: [0]=bottom-left, [1]=top-left, [2]=top-right,
|
|
// optional [3]=alignment pattern. We deliberately do NOT use points[3] for the
|
|
// bottom-right corner: the alignment pattern sits several modules inset from the
|
|
// actual edge of the code, so it's always noticeably closer to the center than the
|
|
// true corner is - using it directly shrinks that whole side of the overlay.
|
|
// Instead derive the true bottom-right corner from the 3 finder-pattern corners,
|
|
// which ZXing locates precisely, assuming the QR is (near) square: BR = TR + BL - TL.
|
|
Point2f pBL = new Point2f(points[0].X, points[0].Y);
|
|
Point2f pTL = new Point2f(points[1].X, points[1].Y);
|
|
Point2f pTR = new Point2f(points[2].X, points[2].Y);
|
|
Point2f pBR = new Point2f(pTR.X + pBL.X - pTL.X, pTR.Y + pBL.Y - pTL.Y);
|
|
|
|
Point2f[] quad = ExpandQuad(pTL, pTR, pBR, pBL, QuadExpansion);
|
|
|
|
seenThisFrame.Add(result.Text);
|
|
if (Tracked.TryGetValue(result.Text, out var t))
|
|
{
|
|
t.Quad = quad;
|
|
t.MissedFrames = 0;
|
|
}
|
|
else
|
|
{
|
|
Tracked[result.Text] = new TrackedQr { Quad = quad, MissedFrames = 0 };
|
|
}
|
|
|
|
EnsureImageLoaded(result.Text);
|
|
}
|
|
|
|
// Age out codes that weren't seen this frame; keep them briefly to avoid flicker.
|
|
List<string> toRemove = null;
|
|
foreach (var kv in Tracked)
|
|
{
|
|
if (seenThisFrame.Contains(kv.Key)) continue;
|
|
kv.Value.MissedFrames++;
|
|
if (kv.Value.MissedFrames > MaxMissedFrames)
|
|
(toRemove ??= new List<string>()).Add(kv.Key);
|
|
}
|
|
if (toRemove != null)
|
|
foreach (var key in toRemove) Tracked.Remove(key);
|
|
|
|
if (!EnableOverlay) return;
|
|
|
|
foreach (var kv in Tracked)
|
|
{
|
|
if (ImageCache.TryGetValue(kv.Key, out Mat img) && img != null && !img.Empty())
|
|
{
|
|
OverlayImagePerspective(frame, img, kv.Value.Quad);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Expand a quad (TL,TR,BR,BL) outward from its centroid by a scale factor.</summary>
|
|
static Point2f[] ExpandQuad(Point2f tl, Point2f tr, Point2f br, Point2f bl, float scale)
|
|
{
|
|
float cx = (tl.X + tr.X + br.X + bl.X) / 4f;
|
|
float cy = (tl.Y + tr.Y + br.Y + bl.Y) / 4f;
|
|
|
|
Point2f Expand(Point2f p) => new Point2f(cx + (p.X - cx) * scale, cy + (p.Y - cy) * scale);
|
|
|
|
return new[] { Expand(tl), Expand(tr), Expand(br), Expand(bl) };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Warp `overlayImage` onto the quadrilateral `quad` (TL,TR,BR,BL) in `frame`, so the
|
|
/// pasted image follows the QR code's rotation/perspective instead of a plain axis-aligned resize.
|
|
/// Only the quad's bounding box is warped, not the whole frame, to keep this fast with
|
|
/// several codes on screen at once.
|
|
/// </summary>
|
|
static void OverlayImagePerspective(Mat frame, Mat overlayImage, Point2f[] quad)
|
|
{
|
|
var intPoints = quad.Select(p => new Point((int)p.X, (int)p.Y)).ToArray();
|
|
Rect rect = Cv2.BoundingRect(intPoints);
|
|
rect = rect.Intersect(new Rect(0, 0, frame.Width, frame.Height));
|
|
if (rect.Width <= 1 || rect.Height <= 1) return;
|
|
|
|
int w = overlayImage.Width, h = overlayImage.Height;
|
|
Point2f[] src = { new(0, 0), new(w, 0), new(w, h), new(0, h) };
|
|
Point2f[] dstLocal = quad.Select(p => new Point2f(p.X - rect.X, p.Y - rect.Y)).ToArray();
|
|
|
|
using Mat homography = Cv2.GetPerspectiveTransform(src, dstLocal);
|
|
using var warped = new Mat();
|
|
Cv2.WarpPerspective(overlayImage, warped, homography, rect.Size);
|
|
|
|
using var mask = new Mat(rect.Size, MatType.CV_8UC1, Scalar.Black);
|
|
var localPoly = dstLocal.Select(p => new Point((int)p.X, (int)p.Y)).ToArray();
|
|
Cv2.FillPoly(mask, new[] { localPoly }, Scalar.White);
|
|
|
|
using var roi = new Mat(frame, rect);
|
|
warped.CopyTo(roi, mask);
|
|
}
|
|
|
|
// ---- decoding ---------------------------------------------------
|
|
|
|
static Result[] ReadQrCodes(Mat gray)
|
|
{
|
|
if (!gray.IsContinuous())
|
|
gray = gray.Clone();
|
|
|
|
int size = (int)(gray.Total() * gray.ElemSize());
|
|
if (_grayBuffer == null || _grayBuffer.Length != size)
|
|
_grayBuffer = new byte[size];
|
|
Marshal.Copy(gray.Data, _grayBuffer, 0, size);
|
|
|
|
// Gray8 skips ZXing's internal RGB->luminance conversion entirely - the fastest
|
|
// format to hand it, and matches what BGR2GRAY already computed for us in OpenCV.
|
|
var luminanceSource = new RGBLuminanceSource(_grayBuffer, gray.Width, gray.Height, RGBLuminanceSource.BitmapFormat.Gray8);
|
|
var bitmap = new BinaryBitmap(new HybridBinarizer(luminanceSource));
|
|
|
|
try
|
|
{
|
|
return MultiReader.decodeMultiple(bitmap, Hints) ?? Array.Empty<Result>();
|
|
}
|
|
catch
|
|
{
|
|
return Array.Empty<Result>();
|
|
}
|
|
}
|
|
|
|
// ---- async image download/caching --------------------------------
|
|
|
|
static void EnsureImageLoaded(string url)
|
|
{
|
|
if (ImageCache.ContainsKey(url) || Pending.ContainsKey(url)) return;
|
|
|
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
|
|
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
|
{
|
|
// Not a downloadable URL - remember that so we don't keep retrying every frame.
|
|
ImageCache[url] = null;
|
|
return;
|
|
}
|
|
|
|
Pending[url] = 0;
|
|
_ = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
byte[] bytes = await Http.GetByteArrayAsync(uri);
|
|
Mat decoded = Mat.FromImageData(bytes, ImreadModes.Color);
|
|
ImageCache[url] = decoded.Empty() ? null : decoded;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"Failed to download {url}: {ex.Message}");
|
|
ImageCache[url] = null; // negative-cache so we stop retrying a bad URL
|
|
}
|
|
finally
|
|
{
|
|
Pending.TryRemove(url, out _);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
} |