using OpenCvSharp; using Serilog; using Region = Poe2Trade.Core.Region; using Point = OpenCvSharp.Point; using Size = OpenCvSharp.Size; namespace Poe2Trade.Navigation; public class MinimapCapture : IDisposable { private readonly MinimapConfig _config; private readonly IScreenCapture _backend; private Mat? _circularMask; public MinimapCapture(MinimapConfig config) { _config = config; _backend = CreateBackend(); BuildCircularMask(); } private static IScreenCapture CreateBackend() { // WGC primary → DXGI fallback → GDI last resort try { var wgc = new WgcCapture(); Log.Information("Screen capture: WGC (Windows Graphics Capture)"); return wgc; } catch (Exception ex) { Log.Warning(ex, "WGC unavailable, trying DXGI Desktop Duplication"); } try { var dxgi = new DesktopDuplication(); Log.Information("Screen capture: DXGI Desktop Duplication"); return dxgi; } catch (Exception ex) { Log.Warning(ex, "DXGI unavailable, falling back to GDI"); } Log.Information("Screen capture: GDI (CopyFromScreen)"); return new GdiCapture(); } private void BuildCircularMask() { var size = _config.CaptureSize; _circularMask = new Mat(size, size, MatType.CV_8UC1, Scalar.Black); var center = new Point(size / 2, size / 2); Cv2.Circle(_circularMask, center, _config.FogRadius, Scalar.White, -1); } public MinimapFrame? CaptureFrame() { var region = _config.CaptureRegion; using var bgr = _backend.CaptureRegion(region); if (bgr == null || bgr.Empty()) return null; // Apply circular mask to ignore area outside fog-of-war circle using var masked = new Mat(); Cv2.BitwiseAnd(bgr, bgr, masked, _circularMask!); // Convert to HSV using var hsv = new Mat(); Cv2.CvtColor(masked, hsv, ColorConversionCodes.BGR2HSV); // Classify explored areas using var exploredMask = new Mat(); Cv2.InRange(hsv, _config.ExploredLoHSV, _config.ExploredHiHSV, exploredMask); // Classify walls: dark pixels within the circular mask using var valueChan = new Mat(); Cv2.ExtractChannel(hsv, valueChan, 2); // V channel using var darkMask = new Mat(); Cv2.Threshold(valueChan, darkMask, _config.WallMaxValue, 255, ThresholdTypes.BinaryInv); // Apply morphological close to connect wall fragments using var wallKernel = Cv2.GetStructuringElement(MorphShapes.Rect, new Size(3, 3)); using var wallMask = new Mat(); Cv2.MorphologyEx(darkMask, wallMask, MorphTypes.Close, wallKernel); // Only within circular mask Cv2.BitwiseAnd(wallMask, _circularMask!, wallMask); // Don't count explored pixels as wall using var notExplored = new Mat(); Cv2.BitwiseNot(exploredMask, notExplored); Cv2.BitwiseAnd(wallMask, notExplored, wallMask); // Build classified mat: Unknown=0, Explored=1, Wall=2 var classified = new Mat(_config.CaptureSize, _config.CaptureSize, MatType.CV_8UC1, Scalar.Black); classified.SetTo(new Scalar((byte)MapCell.Explored), exploredMask); classified.SetTo(new Scalar((byte)MapCell.Wall), wallMask); // Ensure only within circular mask Cv2.BitwiseAnd(classified, _circularMask!, classified); // Detect player marker (orange X) using var playerMask = new Mat(); Cv2.InRange(hsv, _config.PlayerLoHSV, _config.PlayerHiHSV, playerMask); var playerOffset = FindCentroid(playerMask); // Convert to grayscale for phase correlation var gray = new Mat(); Cv2.CvtColor(masked, gray, ColorConversionCodes.BGR2GRAY); // Apply circular mask to gray too Cv2.BitwiseAnd(gray, _circularMask!, gray); return new MinimapFrame( GrayMat: gray, ClassifiedMat: classified, PlayerOffset: playerOffset, Timestamp: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() ); } /// /// Save debug images: raw capture, HSV mask, classified result. /// Call once to diagnose color ranges. /// public void SaveDebugCapture(string dir = "debug-minimap") { Directory.CreateDirectory(dir); var region = _config.CaptureRegion; using var bgr = _backend.CaptureRegion(region); if (bgr == null || bgr.Empty()) return; using var masked = new Mat(); Cv2.BitwiseAnd(bgr, bgr, masked, _circularMask!); using var hsv = new Mat(); Cv2.CvtColor(masked, hsv, ColorConversionCodes.BGR2HSV); using var exploredMask = new Mat(); Cv2.InRange(hsv, _config.ExploredLoHSV, _config.ExploredHiHSV, exploredMask); using var playerMask = new Mat(); Cv2.InRange(hsv, _config.PlayerLoHSV, _config.PlayerHiHSV, playerMask); // Save raw, masked, explored filter, player filter Cv2.ImWrite(Path.Combine(dir, "1-raw.png"), bgr); Cv2.ImWrite(Path.Combine(dir, "2-masked.png"), masked); Cv2.ImWrite(Path.Combine(dir, "3-explored.png"), exploredMask); Cv2.ImWrite(Path.Combine(dir, "4-player.png"), playerMask); // Save HSV channels separately for tuning var channels = Cv2.Split(hsv); Cv2.ImWrite(Path.Combine(dir, "5-hue.png"), channels[0]); Cv2.ImWrite(Path.Combine(dir, "6-sat.png"), channels[1]); Cv2.ImWrite(Path.Combine(dir, "7-val.png"), channels[2]); foreach (var c in channels) c.Dispose(); Log.Information("Debug minimap images saved to {Dir}", Path.GetFullPath(dir)); } private Point2d FindCentroid(Mat mask) { var moments = Cv2.Moments(mask, true); if (moments.M00 < 10) // not enough pixels return new Point2d(0, 0); var cx = moments.M10 / moments.M00 - _config.CaptureSize / 2.0; var cy = moments.M01 / moments.M00 - _config.CaptureSize / 2.0; return new Point2d(cx, cy); } public void Dispose() { _circularMask?.Dispose(); _backend.Dispose(); } }