using System.Buffers; using System.Drawing; using System.Drawing.Imaging; using System.Runtime.InteropServices; namespace GenshinAssistant.InputHelper; /// /// A visual-only rarity signal taken from the row of gold stars on an Artifact /// detail-card crop. It is deliberately conservative: a malformed or /// inconsistent row produces no count instead of guessing a rarity. /// internal readonly record struct ArtifactStarDetection( int? StarCount, double Confidence, string? Source) { internal static ArtifactStarDetection Unknown() => new(null, 0, null); } /// /// Counts the bright gold Artifact-star glyphs inside the stable, normalized /// star-row region of the native 492x838-style detail crop. This detector only /// reads the captured bitmap; it has no screen, process, or input side effects. /// internal static class ArtifactStarDetector { internal const string SourceName = "native-star-row-v1"; private const double MinimumAcceptedConfidence = 0.80; private const double RoiLeft = 0.025; private const double RoiRight = 0.56; private const double RoiTop = 0.245; private const double RoiBottom = 0.345; internal static ArtifactStarDetection Detect(Bitmap? bitmap) { if (bitmap is null || bitmap.Width < 120 || bitmap.Height < 200) { return ArtifactStarDetection.Unknown(); } var roi = StarRowRoiFor(bitmap.Width, bitmap.Height); if (roi.Width < 24 || roi.Height < 16) { return ArtifactStarDetection.Unknown(); } var bytesPerRow = roi.Width * 4; var buffer = ArrayPool.Shared.Rent(bytesPerRow * roi.Height); BitmapData? data = null; try { // Native capture creates Format32bppArgb cards. Locking explicitly // to that format keeps the detector allocation-bounded and avoids // slow per-pixel GDI calls in the scan hot path. data = bitmap.LockBits(roi, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); for (var y = 0; y < roi.Height; y++) { Marshal.Copy( IntPtr.Add(data.Scan0, y * data.Stride), buffer, y * bytesPerRow, bytesPerRow); } } catch { return ArtifactStarDetection.Unknown(); } finally { if (data is not null) { bitmap.UnlockBits(data); } } try { return DetectFromBgra(buffer, roi.Width, roi.Height, bitmap.Width); } finally { ArrayPool.Shared.Return(buffer); } } internal static Rectangle StarRowRoiFor(int width, int height) { if (width <= 0 || height <= 0) return Rectangle.Empty; var left = Math.Clamp((int)Math.Floor(width * RoiLeft), 0, Math.Max(0, width - 1)); var right = Math.Clamp((int)Math.Ceiling(width * RoiRight), left + 1, width); var top = Math.Clamp((int)Math.Floor(height * RoiTop), 0, Math.Max(0, height - 1)); var bottom = Math.Clamp((int)Math.Ceiling(height * RoiBottom), top + 1, height); return Rectangle.FromLTRB(left, top, right, bottom); } private static ArtifactStarDetection DetectFromBgra(byte[] pixels, int width, int height, int cardWidth) { var yellowByColumn = new int[width]; var minYByColumn = Enumerable.Repeat(height, width).ToArray(); var maxYByColumn = Enumerable.Repeat(-1, width).ToArray(); for (var y = 0; y < height; y++) { var rowOffset = y * width * 4; for (var x = 0; x < width; x++) { var offset = rowOffset + x * 4; var blue = pixels[offset]; var green = pixels[offset + 1]; var red = pixels[offset + 2]; if (!IsStrongGold(red, green, blue)) continue; yellowByColumn[x]++; minYByColumn[x] = Math.Min(minYByColumn[x], y); maxYByColumn[x] = Math.Max(maxYByColumn[x], y); } } var minimumYellowPixelsPerColumn = Math.Max(3, (int)Math.Ceiling(height * 0.045)); var rawSegments = FindColumnSegments(yellowByColumn, minYByColumn, maxYByColumn, minimumYellowPixelsPerColumn); var stars = rawSegments .Where(segment => IsPlausibleStarSegment(segment, cardWidth, height)) .ToList(); if (stars.Count is < 1 or > 5) { return ArtifactStarDetection.Unknown(); } if (!HasPlausibleRowGeometry(stars, cardWidth, height)) { return ArtifactStarDetection.Unknown(); } var confidence = CalculateConfidence(stars, cardWidth, height); if (confidence < MinimumAcceptedConfidence) { return ArtifactStarDetection.Unknown(); } return new ArtifactStarDetection(stars.Count, confidence, SourceName); } private static bool IsStrongGold(byte red, byte green, byte blue) { // The card background is warm brown/orange, while a rendered star has a // noticeably brighter yellow core. Favor precision here because a false // five-star classification would make a lower-rarity Artifact appear // ineligible for downstream review instead of preserving uncertainty. return red >= 215 && green >= 165 && blue <= 80 && red >= green + 24 && green >= blue + 72; } private static List FindColumnSegments( int[] yellowByColumn, int[] minYByColumn, int[] maxYByColumn, int minimumYellowPixelsPerColumn) { const int maximumInternalGap = 2; var segments = new List(); var start = -1; var lastActive = -1; for (var x = 0; x < yellowByColumn.Length; x++) { var active = yellowByColumn[x] >= minimumYellowPixelsPerColumn; if (active) { if (start < 0) start = x; lastActive = x; continue; } if (start >= 0 && x - lastActive > maximumInternalGap) { segments.Add(CreateSegment(start, lastActive, yellowByColumn, minYByColumn, maxYByColumn)); start = -1; lastActive = -1; } } if (start >= 0) { segments.Add(CreateSegment(start, lastActive, yellowByColumn, minYByColumn, maxYByColumn)); } return segments; } private static StarSegment CreateSegment( int start, int end, int[] yellowByColumn, int[] minYByColumn, int[] maxYByColumn) { var pixels = 0; var peakColumnPixels = 0; var minY = int.MaxValue; var maxY = -1; for (var x = start; x <= end; x++) { pixels += yellowByColumn[x]; peakColumnPixels = Math.Max(peakColumnPixels, yellowByColumn[x]); minY = Math.Min(minY, minYByColumn[x]); maxY = Math.Max(maxY, maxYByColumn[x]); } return new StarSegment(start, end, pixels, peakColumnPixels, minY, maxY); } private static bool IsPlausibleStarSegment(StarSegment segment, int cardWidth, int roiHeight) { var minimumWidth = Math.Max(8, (int)Math.Round(cardWidth * 0.020)); var maximumWidth = Math.Max(minimumWidth + 1, (int)Math.Round(cardWidth * 0.090)); var minimumHeight = Math.Max(8, (int)Math.Round(roiHeight * 0.10)); var maximumHeight = Math.Max(minimumHeight + 1, (int)Math.Round(roiHeight * 0.75)); var minimumPixels = Math.Max(35, (int)Math.Round(cardWidth * 0.16)); var minimumPeakColumnPixels = Math.Max(8, (int)Math.Round(roiHeight * 0.12)); var density = segment.PixelCount / (double)Math.Max(1, segment.Width * segment.Height); return segment.Width >= minimumWidth && segment.Width <= maximumWidth && segment.Height >= minimumHeight && segment.Height <= maximumHeight && segment.PixelCount >= minimumPixels && segment.PeakColumnPixels >= minimumPeakColumnPixels && density is >= 0.12 and <= 0.78; } private static bool HasPlausibleRowGeometry(IReadOnlyList stars, int cardWidth, int roiHeight) { var firstCenter = stars[0].CenterX; var lastCenter = stars[^1].CenterX; if (firstCenter < cardWidth * 0.035 || firstCenter > cardWidth * 0.16) { return false; } if (lastCenter > cardWidth * 0.47) { return false; } for (var index = 1; index < stars.Count; index++) { var spacing = stars[index].CenterX - stars[index - 1].CenterX; if (spacing < cardWidth * 0.045 || spacing > cardWidth * 0.12) { return false; } } var meanCenterY = stars.Average(star => star.CenterY); return stars.All(star => Math.Abs(star.CenterY - meanCenterY) <= roiHeight * 0.18); } private static double CalculateConfidence(IReadOnlyList stars, int cardWidth, int roiHeight) { var widths = stars.Select(star => (double)star.Width).ToArray(); var heights = stars.Select(star => (double)star.Height).ToArray(); var densities = stars.Select(star => star.PixelCount / (double)Math.Max(1, star.Width * star.Height)).ToArray(); var widthConsistency = Consistency(widths); var heightConsistency = Consistency(heights); var densityQuality = densities.Average(density => Clamp01(1 - Math.Abs(density - 0.45) / 0.45)); var evidence = Clamp01(stars.Sum(star => star.PixelCount) / (stars.Count * cardWidth * 0.35)); var spacingConsistency = 0.82; if (stars.Count > 1) { var spacings = Enumerable.Range(1, stars.Count - 1) .Select(index => stars[index].CenterX - stars[index - 1].CenterX) .ToArray(); spacingConsistency = Consistency(spacings); } var meanCenterY = stars.Average(star => star.CenterY); var alignment = Clamp01(1 - stars.Average(star => Math.Abs(star.CenterY - meanCenterY)) / Math.Max(1, roiHeight * 0.18)); var confidence = 0.20 + 0.20 * widthConsistency + 0.15 * heightConsistency + 0.20 * spacingConsistency + 0.10 * densityQuality + 0.10 * evidence + 0.05 * alignment; return Math.Round(Clamp01(confidence), 3, MidpointRounding.AwayFromZero); } private static double Consistency(IReadOnlyList values) { if (values.Count <= 1) return 0.88; var mean = values.Average(); if (mean <= 0) return 0; var variance = values.Average(value => Math.Pow(value - mean, 2)); return Clamp01(1 - Math.Sqrt(variance) / mean); } private static double Clamp01(double value) => Math.Clamp(value, 0, 1); private sealed record StarSegment(int StartX, int EndX, int PixelCount, int PeakColumnPixels, int MinY, int MaxY) { public int Width => EndX - StartX + 1; public int Height => MaxY - MinY + 1; public double CenterX => (StartX + EndX) / 2.0; public double CenterY => (MinY + MaxY) / 2.0; } }