using Godot;
using System;
using System.Collections.Generic;
using System.Linq;
using OpenCvSharp;
///
/// Detects pupil centers from eye-tracking camera images using OpenCV.
/// Based on threshold-based pupil detection with circularity filtering.
///
public partial class PupilDetector : Node
{
///
/// Represents a detected pupil with its center position and radius.
///
public struct PupilInfo
{
public int X { get; set; }
public int Y { get; set; }
public float Radius { get; set; }
public PupilInfo(int x, int y, float radius)
{
X = x;
Y = y;
Radius = radius;
}
}
private const float MinArea = 200f;
private const float MinRadius = 20f;
private const float MaxRadius = 96f; // Reduced from 120 to filter false positives
private const float DarkestPercentile = 15f; // Keep darkest 15% of pixels
///
/// Finds pupil centers in the given image.
///
/// Input image as OpenCV Mat (BGR or grayscale)
/// List of detected pupils (up to 2), sorted left-to-right
public static List FindPupilCenters(Mat img)
{
if (img == null || img.Empty())
{
GD.PrintErr("PupilDetector: Input image is null or empty");
return new List();
}
// Convert to grayscale if needed
Mat gray = new Mat();
if (img.Channels() > 1)
{
Cv2.CvtColor(img, gray, ColorConversionCodes.BGR2GRAY);
}
else
{
gray = img.Clone();
}
// Calculate threshold value for darkest 15% of pixels
double threshVal = CalculatePercentile(gray, DarkestPercentile);
// Keep only the darkest pixels (robust to uneven lighting)
Mat mask = new Mat();
Cv2.Threshold(gray, mask, threshVal, 255, ThresholdTypes.BinaryInv);
// Clean up noise
Cv2.MedianBlur(mask, mask, 5);
Mat kernel = Cv2.GetStructuringElement(MorphShapes.Ellipse, new OpenCvSharp.Size(5, 5));
Cv2.MorphologyEx(mask, mask, MorphTypes.Open, kernel, iterations: 1);
// Find contours - candidates for pupils (dark, roughly circular blobs)
Cv2.FindContours(mask, out Point[][] contours, out _, RetrievalModes.External, ContourApproximationModes.ApproxSimple);
List<(float score, int x, int y, float radius)> candidates = new List<(float, int, int, float)>();
foreach (var contour in contours)
{
double area = Cv2.ContourArea(contour);
if (area < MinArea)
continue;
double perimeter = Cv2.ArcLength(contour, true);
if (perimeter == 0)
continue;
// Circularity measure: 1.0 = perfect circle
float circularity = (float)(4 * Math.PI * area / (perimeter * perimeter));
Cv2.MinEnclosingCircle(contour, out Point2f center, out float radius);
if (radius < MinRadius || radius > MaxRadius)
continue;
// Score: prefer circular and reasonably large
// Higher circularity closer to 0.8 is better
float score = -Math.Abs(0.8f - circularity) + 0.0001f * (float)area;
candidates.Add((score, (int)center.X, (int)center.Y, radius));
}
// Sort by score (best first) and take top 2
candidates.Sort((a, b) => b.score.CompareTo(a.score));
var topPupils = candidates.Take(2).Select(c => new PupilInfo(c.x, c.y, c.radius)).ToList();
// Sort left-to-right for consistency
topPupils.Sort((a, b) => a.X.CompareTo(b.X));
// Cleanup
gray.Dispose();
mask.Dispose();
kernel.Dispose();
return topPupils;
}
///
/// Calculates the specified percentile value from a grayscale image.
///
private static double CalculatePercentile(Mat gray, float percentile)
{
// Create histogram
int histSize = 256;
float[] range = { 0, 256 };
Rangef[] ranges = { new Rangef(range[0], range[1]) };
Mat hist = new Mat();
Cv2.CalcHist(new Mat[] { gray }, new int[] { 0 }, null, hist, 1, new int[] { histSize }, ranges);
// Calculate total number of pixels
long totalPixels = gray.Rows * gray.Cols;
long targetCount = (long)(totalPixels * (percentile / 100.0));
// Find the threshold value at the percentile
long cumulative = 0;
double thresholdValue = 0;
for (int i = 0; i < histSize; i++)
{
cumulative += (long)hist.At(i);
if (cumulative >= targetCount)
{
thresholdValue = i;
break;
}
}
hist.Dispose();
return thresholdValue;
}
///
/// Visualizes detected pupils on the image for debugging.
///
public static Mat VisualizePupils(Mat img, List pupils)
{
Mat vis = img.Clone();
for (int i = 0; i < pupils.Count; i++)
{
var pupil = pupils[i];
// Scale down radius since minEnclosingCircle is larger than actual pupil
int radiusDraw = (int)(pupil.Radius * 0.7f);
// Draw circle around pupil
Cv2.Circle(vis, new OpenCvSharp.Point(pupil.X, pupil.Y), radiusDraw, new Scalar(0, 255, 0), 2);
// Draw center point
Cv2.Circle(vis, new OpenCvSharp.Point(pupil.X, pupil.Y), 3, new Scalar(0, 255, 0), -1);
// Label left/right
string label = i == 0 ? "L" : "R";
Cv2.PutText(vis, label,
new OpenCvSharp.Point(pupil.X - 10, pupil.Y - radiusDraw - 10),
HersheyFonts.HersheySimplex, 0.6, new Scalar(0, 255, 0), 2);
}
return vis;
}
///
/// Saves a visualization of detected pupils to a file.
///
public static void SaveVisualization(Mat img, List pupils, string outputPath)
{
using Mat vis = VisualizePupils(img, pupils);
Cv2.ImWrite(outputPath, vis);
GD.Print($"PupilDetector: Saved visualization to {outputPath}");
}
}