315 lines
11 KiB
C#
315 lines
11 KiB
C#
using System.Buffers;
|
|
using System.Drawing;
|
|
using System.Drawing.Imaging;
|
|
using System.Runtime.InteropServices;
|
|
|
|
namespace GenshinAssistant.InputHelper;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
internal readonly record struct ArtifactStarDetection(
|
|
int? StarCount,
|
|
double Confidence,
|
|
string? Source)
|
|
{
|
|
internal static ArtifactStarDetection Unknown() => new(null, 0, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<byte>.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<byte>.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<StarSegment> FindColumnSegments(
|
|
int[] yellowByColumn,
|
|
int[] minYByColumn,
|
|
int[] maxYByColumn,
|
|
int minimumYellowPixelsPerColumn)
|
|
{
|
|
const int maximumInternalGap = 2;
|
|
var segments = new List<StarSegment>();
|
|
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<StarSegment> 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<StarSegment> 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<double> 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;
|
|
}
|
|
}
|