using RobotNet10.RobotApp.Devices; using RobotNet10.RobotApp.SLAM; using RobotNet10.Shared.Geometry; // using RobotNet10.Shared.Numbers; using RobotNet10.Shared.Sensor; namespace RobotNet10.RobotApp.Detection; /// /// Session for detecting reflective markers based on shape matching with laser scan data /// Supports 2, 3, or 4 reference points /// IMPORTANT: Marker origin (0,0) in marker frame MUST be at the centroid of reference points /// /// /// Create a new shape reflective marker detection session /// /// Reference points in marker frame (2, 3, or 4 points). Centroid must be at marker origin (0,0). /// Search region for marker center (in global frame) /// Intensity threshold for filtering reflective markers /// OPTICS epsilon parameter for clustering /// OPTICS minimum points parameter /// Reachability threshold for cluster extraction /// Maximum allowed fitting error (meters) /// Thrown when number of reference points is not 2, 3, or 4 public class ShapeReflectiveDetector( Point2D[] markerReferencePoints, RectangleRegion searchRegion, ISLAMService sLAMService, ILidar lidar, Pose lidarPose, double intensityThreshold = 2500, double clusteringEps = 0.1, int clusteringMinPts = 3, double clusterThreshold = 0.5, double maxFitError = 0.05) : IDetector { // Validate number of reference points (must be 2, 3, or 4) private readonly Point2D[] _validatedMarkerPoints = markerReferencePoints.Length is >= 1 and <= 4 ? markerReferencePoints : throw new ArgumentException( $"Number of reference points must be between 2 and 4, but got {markerReferencePoints.Length}", nameof(markerReferencePoints)); private readonly OpticsClusteringAlgorithm _optics = new(clusteringEps, clusteringMinPts); private readonly Lock _lockPose = new(); private readonly Lock _lockScan = new(); private bool _isActive = false; private bool _isProcessing = false; private Thread? _processingThread; private AutoResetEvent? _scanReceivedEvent; private LaserScan? _latestScan; private DateTime _lastScanTime = DateTime.MinValue; /// /// Detected marker pose in global frame /// Initialized with search region center, updated when marker is detected /// public Pose MarkerPose { get { lock (_lockPose) { return field; } } private set { lock (_lockPose) { field = value; } } } = new Pose(new Vector3(searchRegion.Center.X, searchRegion.Center.Y, 0), CreateQuaternionFromYaw(searchRegion.RotationAngle)); /// /// Timestamp of the last successful marker detection /// public DateTime DetectionTime { get; private set; } /// /// Activate the marker detection session /// Starts listening to laser scan data and processes it in background /// public void Active() { if (_isActive) return; _isActive = true; _scanReceivedEvent = new AutoResetEvent(false); // Create and start processing thread _processingThread = new Thread(ProcessingThreadLoop) { Name = "ShapeReflectiveDetection", IsBackground = true }; _processingThread.Start(); // Subscribe to laser scan data lidar.ScanDataReceived += OnScanDataReceived; } /// /// Disable the marker detection session /// Stops processing laser scan data and unsubscribes from events /// public void Disable() { if (!_isActive) return; _isActive = false; // Unsubscribe from laser scan data lidar.ScanDataReceived -= OnScanDataReceived; // Signal the thread to wake up and exit _scanReceivedEvent?.Set(); // Wait for thread to finish _processingThread?.Join(); _processingThread = null; // Dispose wait handle _scanReceivedEvent?.Dispose(); _scanReceivedEvent = null; // Clear latest scan lock (_lockScan) { _latestScan = null; } } /// /// Event handler for laser scan data received /// Stores the latest scan and signals the processing thread /// If already processing, skip this scan to avoid overload /// private void OnScanDataReceived(object? _, LidarScanDataEventArgs e) { // Check if already processing, skip if busy lock (_lockScan) { if (_isProcessing) return; // Store latest scan data _latestScan = e.MeasurementData; _lastScanTime = e.Timestamp; } // Signal the processing thread that new data is available _scanReceivedEvent?.Set(); } /// /// Processing thread loop that waits for new scan data and processes it /// private void ProcessingThreadLoop() { while (_isActive) { // Wait for signal that new scan data is available if (_scanReceivedEvent?.WaitOne() == true) { // Check if still active (might have been disabled) if (!_isActive) break; // Get the latest scan data LaserScan? scanToProcess; lock (_lockScan) { scanToProcess = _latestScan; _latestScan = null; // Clear after reading // Set processing flag if (scanToProcess != null) _isProcessing = true; } // Process the scan if available if (scanToProcess is LaserScan scan) { try { var currentPose = sLAMService.CurrentPose; var markerReferenceSearchRegionsInGlobal = CalculateMarkerReferenceSearchRegionsInGlobal(searchRegion); var markerReferenceSearchRegionsInRobot = TransformRegionsFromGlobalToRobot(markerReferenceSearchRegionsInGlobal, currentPose); var markerReferenceSearchRegions = TransformRegionsFromRobotToLidar(markerReferenceSearchRegionsInRobot, lidarPose); var (angleStart, angleEnd) = CalculateAngleRangeFromRegions(markerReferenceSearchRegions); var points = ConvertLaserScanToPoints(scan, intensityThreshold, angleStart, angleEnd); _optics.ClearPoints(); _optics.AddPoints(points); _optics.Run(); // Extract cluster indices for this region var clusters = _optics.GetClusters(clusterThreshold); // Extract centroids from clusters in each region var centroids = clusters.Select(cluster => CalculateCentroid([.. cluster])).ToList(); // Match centroids to their corresponding search regions var regionCentroids = new List>(); for (int i = 0; i < markerReferenceSearchRegions.Count; i++) { var region = markerReferenceSearchRegions[i]; var matchingCentroids = new List(); foreach (var centroid in centroids) { if (region.ContainsPoint(centroid.X, centroid.Y)) { matchingCentroids.Add(centroid); } } regionCentroids.Add(matchingCentroids); } // Find best matching pose in lidar frame var poseInLidar = FindMatchingPoses(regionCentroids); if (poseInLidar.HasValue) { // Transform pose from lidar frame -> robot frame -> global frame var poseInRobot = TransformPose(poseInLidar.Value, lidarPose); var poseInGlobal = TransformPose(poseInRobot, currentPose); // Update marker pose and detection time MarkerPose = poseInGlobal; DetectionTime = _lastScanTime; } } finally { // Clear processing flag lock (_lockScan) { _isProcessing = false; } } } } } } /// /// Transform search region from global frame to robot frame (inverse transform) /// /// Search region in global frame /// Robot pose in global frame /// Transformed search region in robot frame private static RectangleRegion TransformSearchRegionFromGlobalToRobot(RectangleRegion searchRegion, Pose robotPose) { // Get yaw angle from robot pose double robotYaw = robotPose.Orientation.ToYawRadian(); // Inverse transform: global frame → robot frame // Translate center from global to robot origin (inverse) double dx = searchRegion.Center.X - robotPose.Position.X; double dy = searchRegion.Center.Y - robotPose.Position.Y; // Rotate by negative robot yaw (inverse rotation) double cosInv = Math.Cos(-robotYaw); double sinInv = Math.Sin(-robotYaw); double newCenterX = dx * cosInv - dy * sinInv; double newCenterY = dx * sinInv + dy * cosInv; // Transform rotation angle (subtract robot yaw) double newRotation = searchRegion.RotationAngle - robotYaw; // Normalize angle to [-π, π] newRotation = NormalizeAngle(newRotation); return new RectangleRegion( new Point2D(newCenterX, newCenterY), searchRegion.Width, searchRegion.Height, newRotation); } /// /// Transform search region from robot frame to lidar frame (inverse transform) /// /// Search region in robot frame /// Lidar pose relative to robot base /// Transformed search region in lidar frame private static RectangleRegion TransformSearchRegionInverse(RectangleRegion searchRegion, Pose lidarPose) { // Get yaw angle from lidar pose double lidarYaw = lidarPose.Orientation.ToYawRadian(); // Inverse transform: robot frame → lidar frame // Translate center from robot origin to lidar origin (inverse) double dx = searchRegion.Center.X - lidarPose.Position.X; double dy = searchRegion.Center.Y - lidarPose.Position.Y; // Rotate by negative lidar yaw (inverse rotation) double cosInv = Math.Cos(-lidarYaw); double sinInv = Math.Sin(-lidarYaw); double newCenterX = dx * cosInv - dy * sinInv; double newCenterY = dx * sinInv + dy * cosInv; // Transform rotation angle (subtract lidar yaw) double newRotation = searchRegion.RotationAngle - lidarYaw; // Normalize angle to [-π, π] newRotation = NormalizeAngle(newRotation); return new RectangleRegion( new Point2D(newCenterX, newCenterY), searchRegion.Width, searchRegion.Height, newRotation); } /// /// Calculate search regions for each marker reference point in global frame /// Transforms each reference point from marker frame to global frame /// based on predicted marker pose, then creates search region around it /// Each search region inherits size from the main search region to account for pose uncertainty /// /// Predicted marker pose (center + rotation) in global frame /// List of search regions for each reference point in global frame private List CalculateMarkerReferenceSearchRegionsInGlobal(RectangleRegion searchRegion) { var regions = new List(); // Use search region center and rotation as predicted marker pose in global frame double markerX = searchRegion.Center.X; double markerY = searchRegion.Center.Y; double markerRotation = searchRegion.RotationAngle; double cosTheta = Math.Cos(markerRotation); double sinTheta = Math.Sin(markerRotation); // Transform each reference point from marker frame to global frame for (int i = 0; i < _validatedMarkerPoints.Length; i++) { var refPoint = _validatedMarkerPoints[i]; // Apply rotation and translation to transform from marker frame to global frame double pointX = markerX + refPoint.X * cosTheta - refPoint.Y * sinTheta; double pointY = markerY + refPoint.X * sinTheta + refPoint.Y * cosTheta; // Create search region centered at this predicted point // Use same size as main search region to account for pose uncertainty regions.Add(new RectangleRegion( new Point2D(pointX, pointY), searchRegion.Width, searchRegion.Height, 0)); // Axis-aligned for simplicity } return regions; } /// /// Transform a list of rectangle regions from global frame to robot frame /// /// List of regions in global frame /// Robot pose in global frame /// List of regions in robot frame private static List TransformRegionsFromGlobalToRobot(List regionsInGlobal, Pose robotPose) { var regionsInRobot = new List(); foreach (var region in regionsInGlobal) { var transformedRegion = TransformSearchRegionFromGlobalToRobot(region, robotPose); regionsInRobot.Add(transformedRegion); } return regionsInRobot; } /// /// Transform a list of rectangle regions from robot frame to lidar frame /// /// List of regions in robot frame /// Lidar pose relative to robot base /// List of regions in lidar frame private static List TransformRegionsFromRobotToLidar(List regionsInRobot, Pose lidarPose) { var regionsInLidar = new List(); foreach (var region in regionsInRobot) { var transformedRegion = TransformSearchRegionInverse(region, lidarPose); regionsInLidar.Add(transformedRegion); } return regionsInLidar; } /// /// Calculate the angle range needed to cover all search regions /// This optimizes laser scan processing by only considering relevant angles /// /// Search regions to analyze /// Tuple of (angleStart, angleEnd) in radians private static (double angleStart, double angleEnd) CalculateAngleRangeFromRegions(List regions) { double minAngle = double.MaxValue; double maxAngle = double.MinValue; foreach (var region in regions) { // Get the 4 corners of the rectangle var corners = GetRectangleCorners(region); // Calculate angle to each corner from origin (lidar position at 0,0) foreach (var corner in corners) { double angle = Math.Atan2(corner.Y, corner.X); if (angle < minAngle) minAngle = angle; if (angle > maxAngle) maxAngle = angle; } } // Add small margin (5 degrees) to ensure we don't miss any points at the boundaries const double margin = 5.0 * Math.PI / 180.0; // 5 degrees in radians minAngle -= margin; maxAngle += margin; // Clamp to valid angle range [-π, π] minAngle = Math.Max(minAngle, -Math.PI); maxAngle = Math.Min(maxAngle, Math.PI); return (minAngle, maxAngle); } /// /// Get the 4 corners of a rectangle region /// /// Rectangle region /// List of 4 corner points in the same frame as the input region private static List GetRectangleCorners(RectangleRegion region) { double halfWidth = region.Width / 2.0; double halfHeight = region.Height / 2.0; // Define 4 corners in local frame (before rotation) var localCorners = new List<(double x, double y)> { (-halfWidth, -halfHeight), (halfWidth, -halfHeight), (halfWidth, halfHeight), (-halfWidth, halfHeight) }; // Transform to region frame (apply rotation and translation) var corners = new List(); double cosTheta = Math.Cos(region.RotationAngle); double sinTheta = Math.Sin(region.RotationAngle); foreach (var (x, y) in localCorners) { double worldX = region.Center.X + x * cosTheta - y * sinTheta; double worldY = region.Center.Y + x * sinTheta + y * cosTheta; corners.Add(new Point2D(worldX, worldY)); } return corners; } /// /// Find best matching pose from region centroids /// Returns the pose with the lowest fitting error /// /// Centroids from each search region /// Best detected pose, or null if no valid match found private Pose? FindMatchingPoses(List> regionCentroids) { // Generate all combinations of centroids (one from each region) // GenerateCombinations already validates that all regions have clusters var combinations = GenerateCombinations(regionCentroids); if (combinations.Count == 0) { return null; } Pose? bestPose = null; double bestError = double.MaxValue; int validPoseCount = 0; int acceptableErrorCount = 0; foreach (var combination in combinations) { // Try to estimate pose from this combination var pose = EstimatePoseFromPoints(_validatedMarkerPoints, combination); if (pose.HasValue) { validPoseCount++; // Calculate fitting error double error = CalculateFitError(_validatedMarkerPoints, combination, pose.Value); // Check if error is acceptable and better than previous best if (error <= maxFitError && error < bestError) { acceptableErrorCount++; bestPose = pose.Value; bestError = error; } } } return bestPose; } /// /// Generate all valid combinations of centroids from different regions /// IMPORTANT: Each combination[i] must come from regionCentroids[i] to maintain correct correspondence /// with _validatedMarkerPoints[i]. Skipping regions is NOT allowed. /// /// Centroids from each region /// List of point combinations private List> GenerateCombinations(List> regionCentroids) { var combinations = new List>(); // Ensure we have exactly N regions (one per reference point) int numReferencePoints = _validatedMarkerPoints.Length; if (regionCentroids.Count != numReferencePoints) { return combinations; // Return empty if mismatch } // Check if ALL required regions have at least one cluster // If ANY region is empty, we cannot form valid combinations for (int i = 0; i < numReferencePoints; i++) { if (regionCentroids[i].Count == 0) { return combinations; // Return empty - missing required points } } // Generate combinations where combination[i] comes from regionCentroids[i] void GenerateRecursive(int regionIndex, List current) { // Base case: processed all regions if (regionIndex == numReferencePoints) { combinations.Add([.. current]); return; } // Try each centroid from the CURRENT region only (no skipping!) foreach (var centroid in regionCentroids[regionIndex]) { current.Add(centroid); GenerateRecursive(regionIndex + 1, current); current.RemoveAt(current.Count - 1); } } GenerateRecursive(0, []); return combinations; } /// /// Estimate marker pose from reference points and measured points /// Uses a simplified point set registration algorithm /// /// Reference points in marker frame /// Measured points in lidar frame /// Estimated pose in lidar frame, or null if estimation fails private static Pose? EstimatePoseFromPoints(Point2D[] referencePoints, List measuredPoints) { if (referencePoints.Length != measuredPoints.Count) { return null; } // Handle single-point detection if (referencePoints.Length == 1) { // For single point: marker position = measured position - reference point offset // Since we don't know rotation, assume rotation = 0 // Marker position = measured point - reference point (with rotation 0) double marker_X = measuredPoints[0].X - referencePoints[0].X; double marker_Y = measuredPoints[0].Y - referencePoints[0].Y; return new Pose { Position = new Vector3 { X = marker_X, Y = marker_Y, Z = 0 }, Orientation = CreateQuaternionFromYaw(0) // Cannot determine rotation from single point }; } if (referencePoints.Length < 2) { return null; } // Calculate centroids var refCentroid = CalculateCentroid(referencePoints); var measCentroid = CalculateCentroid([.. measuredPoints]); // Center the point sets var refCentered = referencePoints.Select(p => new Point2D(p.X - refCentroid.X, p.Y - refCentroid.Y)).ToList(); var measCentered = measuredPoints.Select(p => new Point2D(p.X - measCentroid.X, p.Y - measCentroid.Y)).ToList(); // Calculate rotation using SVD-like approach (simplified for 2D) double theta = EstimateRotation(refCentered, measCentered); // Calculate marker origin position in lidar frame // Formula: t = M_centroid - R(theta) * R_centroid // This accounts for cases where reference centroid is not at marker origin (0,0) double cosTheta = Math.Cos(theta); double sinTheta = Math.Sin(theta); // R(theta) * refCentroid double rotatedRefX = refCentroid.X * cosTheta - refCentroid.Y * sinTheta; double rotatedRefY = refCentroid.X * sinTheta + refCentroid.Y * cosTheta; // t = measCentroid - R(theta) * refCentroid double markerX = measCentroid.X - rotatedRefX; double markerY = measCentroid.Y - rotatedRefY; var pose = new Pose { Position = new Vector3 { X = markerX, Y = markerY, Z = 0 }, Orientation = CreateQuaternionFromYaw(theta) }; return pose; } /// /// Estimate rotation angle between two centered point sets /// private static double EstimateRotation(List refCentered, List measCentered) { // Use cross-covariance method double sxx = 0, sxy = 0, syx = 0, syy = 0; for (int i = 0; i < refCentered.Count; i++) { sxx += measCentered[i].X * refCentered[i].X; sxy += measCentered[i].X * refCentered[i].Y; syx += measCentered[i].Y * refCentered[i].X; syy += measCentered[i].Y * refCentered[i].Y; } // Calculate rotation angle using atan2 double theta = Math.Atan2(syx - sxy, sxx + syy); return theta; } /// /// Calculate fitting error between reference and measured points given a pose /// Error represents the average Euclidean distance between: /// - Predicted positions: where reference points SHOULD be (based on estimated pose) /// - Measured positions: where reflective markers were ACTUALLY detected by lidar /// /// Lower error = better match between model and reality /// /// Reference points in marker frame (model) /// Measured points in lidar frame (reality) /// Estimated marker pose to validate /// Average distance error in meters private static double CalculateFitError(Point2D[] referencePoints, List measuredPoints, Pose pose) { if (referencePoints.Length != measuredPoints.Count) return double.MaxValue; double totalError = 0; double theta = pose.Orientation.ToYawRadian(); // Pre-calculate cos and sin (optimization - computed once instead of per iteration) double cosTheta = Math.Cos(theta); double sinTheta = Math.Sin(theta); for (int i = 0; i < referencePoints.Length; i++) { // Transform reference point from marker frame to lidar frame using the estimated pose // Formula: P_predicted = t + R(theta) * P_reference double transformedX = pose.Position.X + referencePoints[i].X * cosTheta - referencePoints[i].Y * sinTheta; double transformedY = pose.Position.Y + referencePoints[i].X * sinTheta + referencePoints[i].Y * cosTheta; // Calculate Euclidean distance between predicted and measured positions double dx = transformedX - measuredPoints[i].X; double dy = transformedY - measuredPoints[i].Y; double distance = Math.Sqrt(dx * dx + dy * dy); totalError += distance; } double averageError = totalError / referencePoints.Length; return averageError; } /// /// Create a quaternion from yaw angle (rotation around Z axis) /// private static RobotNet10.Shared.Geometry.Quaternion CreateQuaternionFromYaw(double yaw) { double halfYaw = yaw / 2.0; return new RobotNet10.Shared.Geometry.Quaternion { X = 0, Y = 0, Z = Math.Sin(halfYaw), W = Math.Cos(halfYaw) }; } /// /// Normalize angle to [-π, π] /// private static double NormalizeAngle(double angle) { while (angle > Math.PI) angle -= 2 * Math.PI; while (angle < -Math.PI) angle += 2 * Math.PI; return angle; } /// /// Transform a pose by another pose (pose composition) /// result = parentPose * childPose /// private static Pose TransformPose(Pose childPose, Pose parentPose) { // Get yaw angles double parentYaw = parentPose.Orientation.ToYawRadian(); double childYaw = childPose.Orientation.ToYawRadian(); // Rotate child position by parent orientation double cosParent = Math.Cos(parentYaw); double sinParent = Math.Sin(parentYaw); double globalX = parentPose.Position.X + childPose.Position.X * cosParent - childPose.Position.Y * sinParent; double globalY = parentPose.Position.Y + childPose.Position.X * sinParent + childPose.Position.Y * cosParent; // Combine orientations double globalYaw = NormalizeAngle(parentYaw + childYaw); return new Pose { Position = new Vector3 { X = globalX, Y = globalY, Z = parentPose.Position.Z + childPose.Position.Z }, Orientation = CreateQuaternionFromYaw(globalYaw) }; } private static List ConvertLaserScanToPoints(LaserScan scan, double intensityThreshold, double angleStart, double angleEnd) { var points = new List(); // Skip invalid scans if (scan.Ranges.Length == 0) { return points; } int invalidRanges = 0; int filteredByAngle = 0; int filteredByIntensity = 0; double normalizedStart = NormalizeAngle(angleStart); double normalizedEnd = NormalizeAngle(angleEnd); for (int i = 0; i < scan.Ranges.Length; i++) { double range = scan.Ranges[i]; // Skip invalid ranges (out of bounds or NaN/Infinity) if (double.IsNaN(range) || double.IsInfinity(range) || range < scan.RangeMin || range > scan.RangeMax) { invalidRanges++; continue; } // Calculate angle for this measurement double alpha = scan.AngleMin + (i * scan.AngleIncrement); // Normalize alpha to [-π, π] for proper comparison double normalizedAlpha = NormalizeAngle(alpha); // Handle angle wrapping around (e.g., from -π to π) if (normalizedStart <= normalizedEnd) { // Normal case: angleStart < angleEnd if (normalizedAlpha < normalizedStart || normalizedAlpha > normalizedEnd) { filteredByAngle++; continue; } } else { // Wrapped case: angleEnd < angleStart (e.g., 3π/4 to -3π/4) if (normalizedAlpha < normalizedStart && normalizedAlpha > normalizedEnd) { filteredByAngle++; continue; } } // Filter by intensity if threshold is set and intensities are available if (scan.Intensities.Length > i) { if (scan.Intensities[i] < intensityThreshold) { filteredByIntensity++; continue; } } points.Add(new Point(range * Math.Cos(alpha), range * Math.Sin(alpha), range, alpha)); } return points; } public static Point2D CalculateCentroid(Point2D[] cluster) { if (cluster.Length == 0) return new Point2D(0, 0); double sumX = 0; double sumY = 0; foreach (var point in cluster) { sumX += point.X; sumY += point.Y; } return new Point2D(sumX / cluster.Length, sumY / cluster.Length); } public void Dispose() { // Disable detector (stops thread, unsubscribes events, disposes resources) Disable(); // Suppress finalization since we've cleaned up GC.SuppressFinalize(this); } } public readonly struct RectangleRegion(Point2D center, double width, double height, double rotationAngle) { /// /// Center point of the rectangle /// public Point2D Center { get; init; } = center; /// /// Width of the rectangle (meters) /// public double Width { get; init; } = width; /// /// Height of the rectangle (meters) /// public double Height { get; init; } = height; /// /// Rotation angle in radians (counterclockwise from positive X-axis) /// public double RotationAngle { get; init; } = rotationAngle; public RectangleRegion(double centerX, double centerY, double width, double height, double rotationAngle) : this(new Point2D(centerX, centerY), width, height, rotationAngle) { } /// /// Check if a point is contained within this rectangle /// Uses coordinate transformation to handle rotation efficiently /// /// Point to check /// True if point is inside the rectangle public bool ContainsPoint(double x, double y) { // Translate point to rectangle's local coordinate system (center at origin) double dx = x - Center.X; double dy = y - Center.Y; // Rotate point by negative rotation angle to align with rectangle axes double cosTheta = Math.Cos(-RotationAngle); double sinTheta = Math.Sin(-RotationAngle); double localX = dx * cosTheta - dy * sinTheta; double localY = dx * sinTheta + dy * cosTheta; // Check if point is within rectangle bounds double halfWidth = Width / 2.0; double halfHeight = Height / 2.0; return Math.Abs(localX) <= halfWidth && Math.Abs(localY) <= halfHeight; } }