diff --git a/.gitignore b/.gitignore index 46ca6dd..52d5917 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ /qrCamera/bin /qrCamera/obj /qrCamera/cache +/qrCameraAI/bin +/qrCameraAI/obj +/qrCameraAI/cache /createqrcodes/bin /createqrcodes/obj /cache diff --git a/qrCameraAI/CameraPreview.csproj b/qrCameraAI/CameraPreview.csproj new file mode 100644 index 0000000..e337f10 --- /dev/null +++ b/qrCameraAI/CameraPreview.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + diff --git a/qrCameraAI/Program.cs b/qrCameraAI/Program.cs new file mode 100644 index 0000000..e6418e8 --- /dev/null +++ b/qrCameraAI/Program.cs @@ -0,0 +1,283 @@ +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 ImageCache = new(); // text -> decoded image (null = failed) + static readonly ConcurrentDictionary Pending = new(); // text currently downloading + + static readonly QRCodeReader QrReader = new QRCodeReader(); + static readonly GenericMultipleBarcodeReader MultiReader = new GenericMultipleBarcodeReader(QrReader); + static readonly Dictionary Hints = new() + { + { DecodeHintType.POSSIBLE_FORMATS, new List { BarcodeFormat.QR_CODE } }, + { DecodeHintType.TRY_HARDER, TryHarderMultiDecode }, + }; + + static byte[] _grayBuffer; + static readonly Dictionary 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(); + + 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 toRemove = null; + foreach (var kv in Tracked) + { + if (seenThisFrame.Contains(kv.Key)) continue; + kv.Value.MissedFrames++; + if (kv.Value.MissedFrames > MaxMissedFrames) + (toRemove ??= new List()).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); + } + } + } + + /// Expand a quad (TL,TR,BR,BL) outward from its centroid by a scale factor. + 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) }; + } + + /// + /// 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. + /// + 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(); + } + catch + { + return Array.Empty(); + } + } + + // ---- 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 _); + } + }); + } + } +} \ No newline at end of file