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;
}
}