feat(scanner): complete localized artifact quality checkpoint
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user