From 8126e553f72d59d193360a3cfe4186c6b382520e Mon Sep 17 00:00:00 2001 From: TheMaddax Date: Wed, 8 Jan 2025 09:34:22 -0600 Subject: [PATCH] Round 3 --- ChessPrism/ChessPrism/BoardDetector.swift | 127 +- .../analytics/coremldata.bin | Bin 0 -> 217 bytes .../coremldata.bin | Bin 0 -> 416 bytes .../metadata.json | 86 ++ .../model0/coremldata.bin | Bin 0 -> 171 bytes .../model1/coremldata.bin | Bin 0 -> 68114 bytes ChessPrism/ChessPrism/ContentView.swift | 249 +++- .../ChessPrism/Models/ChessPosition.swift | 274 ++++ .../Recognition/PieceRecognizer.swift | 397 ++++++ .../ChessPrism/ScreenCaptureViewModel.swift | 89 +- cline_docs/Info.txt | 1162 +++++++++-------- cline_docs/activeContext.md | 121 +- cline_docs/chessboard.txt | 20 + cline_docs/error | 138 -- cline_docs/productContext.md | 233 +--- cline_docs/systemPatterns.md | 279 +--- cline_docs/techContext.md | 294 +---- 17 files changed, 1941 insertions(+), 1528 deletions(-) create mode 100644 ChessPrism/ChessPrism/ChessPieceClassifier.mlmodelc/analytics/coremldata.bin create mode 100644 ChessPrism/ChessPrism/ChessPieceClassifier.mlmodelc/coremldata.bin create mode 100644 ChessPrism/ChessPrism/ChessPieceClassifier.mlmodelc/metadata.json create mode 100644 ChessPrism/ChessPrism/ChessPieceClassifier.mlmodelc/model0/coremldata.bin create mode 100644 ChessPrism/ChessPrism/ChessPieceClassifier.mlmodelc/model1/coremldata.bin create mode 100644 ChessPrism/ChessPrism/Models/ChessPosition.swift create mode 100644 ChessPrism/ChessPrism/Recognition/PieceRecognizer.swift create mode 100644 cline_docs/chessboard.txt delete mode 100644 cline_docs/error diff --git a/ChessPrism/ChessPrism/BoardDetector.swift b/ChessPrism/ChessPrism/BoardDetector.swift index d5d99e6..1e8f70b 100644 --- a/ChessPrism/ChessPrism/BoardDetector.swift +++ b/ChessPrism/ChessPrism/BoardDetector.swift @@ -2,46 +2,46 @@ import Foundation import CoreImage import Vision -class BoardDetector { +/// Errors that can occur during board detection and processing +enum BoardDetectionError: Error { + case boardNotFound + case invalidBoardDimensions + case squareExtractionFailed + case imageProcessingFailed +} + +final class BoardDetector { // Share CIContext to avoid creating too many Metal command queues private static let shared = CIContext() private var context: CIContext { BoardDetector.shared } func detectBoard(in image: CIImage) -> CGRect? { - // Configure rectangle detection request let request = VNDetectRectanglesRequest() - request.minimumAspectRatio = 0.8 // Adjusted for standard chess board + request.minimumAspectRatio = 0.8 request.maximumAspectRatio = 1.2 request.minimumSize = 0.4 request.maximumObservations = 1 request.quadratureTolerance = 30 request.minimumConfidence = 0.9 - // Perform the request let requestHandler = VNImageRequestHandler(ciImage: image, options: [:]) do { try requestHandler.perform([request]) } catch { - print("Failed to perform rectangle detection: \(error)") + print("ERROR: Rectangle detection failed - \(error)") return nil } - // Process results guard let observations = request.results, !observations.isEmpty else { return nil } let bestObservation = observations[0] - - // Convert Vision coordinates to CoreImage coordinates let imageSize = image.extent.size let transform = CGAffineTransform(scaleX: imageSize.width, y: imageSize.height) - - // Create normalized rect in CoreImage coordinate space let detectedRect = bestObservation.boundingBox.applying(transform) - // Validate the detected rectangle guard validateDetectedRect(detectedRect, in: image) else { return nil } @@ -49,28 +49,123 @@ class BoardDetector { return detectedRect } + /// Piece recognizer for analyzing extracted squares + private let pieceRecognizer: PieceRecognizer + + /// Initialize with a piece recognizer + init(pieceRecognizer: PieceRecognizer) { + self.pieceRecognizer = pieceRecognizer + } + + /// Extract a specific square from the board image + private func extractSquare(from image: CIImage, in rect: CGRect, at position: BoardPosition) throws -> CGImage { + let squareSize = rect.width / 8 + let x = rect.minX + CGFloat(position.file) * squareSize + let y = rect.minY + CGFloat(position.rank) * squareSize + let squareRect = CGRect(x: x, y: y, width: squareSize, height: squareSize) + + guard image.extent.contains(squareRect) else { + print("ERROR: Square bounds outside image extent at \(position.file),\(position.rank)") + throw BoardDetectionError.squareExtractionFailed + } + + let croppedImage = image.cropped(to: squareRect) + + guard let cgImage = context.createCGImage(croppedImage, from: croppedImage.extent) else { + print("ERROR: Failed to create square image at \(position.file),\(position.rank)") + throw BoardDetectionError.squareExtractionFailed + } + + return cgImage + } + + /// Analyze the board and return the chess position + func analyzeBoard(in image: CIImage) async throws -> ChessPosition { + print("=== ANALYZING BOARD ===") + + guard let boardRect = detectBoard(in: image) else { + print("ERROR: Board detection failed") + throw BoardDetectionError.boardNotFound + } + + let aspectRatio = boardRect.width / boardRect.height + guard boardRect.width > 0, boardRect.height > 0, + abs(1 - aspectRatio) < 0.1 else { + print("ERROR: Invalid board dimensions") + throw BoardDetectionError.invalidBoardDimensions + } + + var position = ChessPosition() + var recognizedPieces = 0 + + // Reset piece counts before starting new scan + pieceRecognizer.resetPieceCounts() + + // Process each square + for rank in 0..<8 { + for file in 0..<8 { + let boardPosition = BoardPosition(file: file, rank: rank) + let squareImage = try extractSquare(from: image, in: boardRect, at: boardPosition) + + // Set the current position being analyzed + pieceRecognizer.currentPosition = boardPosition + + if let piece = try await pieceRecognizer.recognizePiece(from: squareImage) { + position[boardPosition] = piece + recognizedPieces += 1 + } + } + } + + // Validate piece counts after all squares are processed + try pieceRecognizer.validatePieceCounts() + + print("\nPieces recognized: \(recognizedPieces)") + print("\nRecognized position:") + for rank in (0...7).reversed() { + var rankStr = "\(rank + 1) " + for file in 0...7 { + if let piece = position[BoardPosition(file: file, rank: rank)] { + rankStr += "\(piece.color == .white ? "w" : "b")\(piece.type.fenSymbol.uppercased()) " + } else { + rankStr += ".. " + } + } + print(rankStr) + } + print(" a b c d e f g h") + + guard position.isValid else { + print("\nERROR: Invalid chess position") + throw BoardDetectionError.imageProcessingFailed + } + + print("=== BOARD ANALYSIS COMPLETE ===") + return position + } + private func validateDetectedRect(_ rect: CGRect, in image: CIImage) -> Bool { let imageSize = image.extent.size - // Check if rectangle is within image bounds guard image.extent.contains(rect) else { + print("ERROR: Detected rectangle outside image bounds") return false } - // Validate aspect ratio (standard chess board is square) let aspectRatio = rect.width / rect.height guard aspectRatio >= 0.9 && aspectRatio <= 1.1 else { + print("ERROR: Invalid board aspect ratio: \(aspectRatio)") return false } - // Validate size relative to image let minDimension = min(imageSize.width, imageSize.height) let boardSize = max(rect.width, rect.height) - guard boardSize >= minDimension * 0.4 else { + let sizeRatio = boardSize / minDimension + guard sizeRatio >= 0.4 else { + print("ERROR: Board too small relative to image") return false } return true } - } diff --git a/ChessPrism/ChessPrism/ChessPieceClassifier.mlmodelc/analytics/coremldata.bin b/ChessPrism/ChessPrism/ChessPieceClassifier.mlmodelc/analytics/coremldata.bin new file mode 100644 index 0000000000000000000000000000000000000000..20bdbe3081e488864164c99401b31fcc674a673d GIT binary patch literal 217 zcmWe(fPjF^g4CSMyj0)(l++xT)RM%^oMJ|(93Pa<1SmB-Alaug8lp}%BmuAL-H+B!u^7@ u4cvnqldFt;j8al^9c@tz@k`82h1u+!ky>0FkeQmC>YS5UTnuzXY7qck6f>a! literal 0 HcmV?d00001 diff --git a/ChessPrism/ChessPrism/ChessPieceClassifier.mlmodelc/coremldata.bin b/ChessPrism/ChessPrism/ChessPieceClassifier.mlmodelc/coremldata.bin new file mode 100644 index 0000000000000000000000000000000000000000..cf4c86259982c8a1080c11f6a15693385b9bf6e8 GIT binary patch literal 416 zcmZvYO-{ow5QS4it;j6vdrGP`6$ zS&0%kyNlx;Ip2}1(dvXzCrpWs6CSOu*~PC~8l8(=D(N=-_=zOZbHMWdfTv#remZ|M z(%vq_hpL2m_w<2{3SEJyYlTo46b>uZN@&nnsn^NT3^Gse8Smi^49%u6fz;C(8+R~m u+OT~!P#bI{euCT7n4Il@I9I}2`6|(PI_}MH_i&<2hS@ZQ2?@1r!}|@8TWa0_ literal 0 HcmV?d00001 diff --git a/ChessPrism/ChessPrism/ChessPieceClassifier.mlmodelc/metadata.json b/ChessPrism/ChessPrism/ChessPieceClassifier.mlmodelc/metadata.json new file mode 100644 index 0000000..aef216c --- /dev/null +++ b/ChessPrism/ChessPrism/ChessPieceClassifier.mlmodelc/metadata.json @@ -0,0 +1,86 @@ +[ + { + "metadataOutputVersion" : "3.0", + "outputSchema" : [ + { + "isOptional" : "0", + "formattedType" : "String", + "type" : "String", + "name" : "target", + "shortDescription" : "" + }, + { + "isOptional" : "0", + "keyType" : "String", + "formattedType" : "Dictionary (String → Double)", + "type" : "Dictionary", + "name" : "targetProbability", + "shortDescription" : "" + } + ], + "modelParameters" : [ + + ], + "author" : "Chris Haulmark", + "specificationVersion" : 8, + "isUpdatable" : "0", + "stateSchema" : [ + + ], + "availability" : { + "macOS" : "14.0", + "tvOS" : "17.0", + "visionOS" : "1.0", + "watchOS" : "unavailable", + "iOS" : "17.0", + "macCatalyst" : "17.0" + }, + "modelType" : { + "name" : "MLModelType_imageClassifier", + "structure" : [ + { + "name" : "MLModelType_visionFeaturePrint" + }, + { + "name" : "MLModelType_glmClassifier" + } + ] + }, + "inputSchema" : [ + { + "height" : "360", + "colorspace" : "BGR", + "isOptional" : "0", + "width" : "360", + "isColor" : "1", + "formattedType" : "Image (Color 360 × 360)", + "hasSizeFlexibility" : "0", + "type" : "Image", + "shortDescription" : "", + "name" : "image" + } + ], + "classLabels" : [ + "black_bishop", + "black_king", + "black_knight", + "black_pawn", + "black_queen", + "black_rook", + "white_bishop", + "white_king", + "white_knight", + "white_pawn", + "white_queen", + "white_rook" + ], + "generatedClassName" : "ChessPieceClassifier", + "userDefinedMetadata" : { + "com.apple.createml.version" : "15.3.0", + "com.apple.createml.app.tag" : "150.3", + "com.apple.coreml.model.preview.type" : "imageClassifier", + "com.apple.createml.app.version" : "6.1" + }, + "method" : "predict" + } +] \ No newline at end of file diff --git a/ChessPrism/ChessPrism/ChessPieceClassifier.mlmodelc/model0/coremldata.bin b/ChessPrism/ChessPrism/ChessPieceClassifier.mlmodelc/model0/coremldata.bin new file mode 100644 index 0000000000000000000000000000000000000000..3ec9c2dfbf5ec866157eebcd004e07948c3d0216 GIT binary patch literal 171 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ℑKt`64a+@oP{ZL}C66&d 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

reqzgyz0 zu(ei?ALa6U_d^Qe$KF&l{<#QopWkNZKVN}(Z{DxyukJPY8&>?8pFPB>WAe}NuGHh4 zlzuu4-cdOFZ|XMeL>Zn>@ram)&c(~w)$@YOl>m1iUmBYKUU^>ezWjeb@$|lO**)<8 ze&XvK>`g=a-*5Q^xw(1&-w*tKd^~CBg59qKy8Zu){rBp>i~aAv|Mz15z54HB|NZ-a MFZSPy|GC)z1KjlPNB{r; literal 0 HcmV?d00001 diff --git a/ChessPrism/ChessPrism/ContentView.swift b/ChessPrism/ChessPrism/ContentView.swift index d7c36d1..50e18eb 100644 --- a/ChessPrism/ChessPrism/ContentView.swift +++ b/ChessPrism/ChessPrism/ContentView.swift @@ -1,6 +1,84 @@ import SwiftUI import AppKit +// MARK: - Chess Position View Components + +struct ChessPieceView: View { + let piece: ChessPiece + + var body: some View { + Text(pieceSymbol) + .font(.system(size: 24, weight: .bold)) + .foregroundColor(piece.color == .white ? .white : .black) + } + + private var pieceSymbol: String { + switch piece.type { + case .pawn: return "♟" + case .knight: return "♞" + case .bishop: return "♝" + case .rook: return "♜" + case .queen: return "♛" + case .king: return "♚" + } + } +} + +struct ChessboardSquareView: View { + let isLightSquare: Bool + let piece: ChessPiece? + + var body: some View { + ZStack { + Rectangle() + .fill(isLightSquare ? Color.white : Color.gray) + .frame(width: 40, height: 40) + + if let piece = piece { + ChessPieceView(piece: piece) + } + } + } +} + +struct ChessboardView: View { + let position: ChessPosition? + + var body: some View { + VStack(spacing: 0) { + ForEach((0..<8).reversed(), id: \.self) { rank in + HStack(spacing: 0) { + ForEach(0..<8, id: \.self) { file in + let boardPosition = BoardPosition(file: file, rank: rank) + ChessboardSquareView( + isLightSquare: (rank + file) % 2 == 0, + piece: position?[boardPosition] + ) + } + } + } + } + .border(Color.black, width: 1) + } +} + +struct AnalysisStatusView: View { + let isAnalyzing: Bool + let confidence: Double + + var body: some View { + VStack(spacing: 8) { + if isAnalyzing { + ProgressView("Analyzing position...") + } else { + Text("Recognition Confidence: \(Int(confidence * 100))%") + .foregroundColor(confidence > 0.7 ? .green : .orange) + } + } + .padding() + } +} + class CustomNSView: NSView { private static let invisibleCursor: NSCursor = { let image = NSImage(size: NSSize(width: 1, height: 1)) @@ -84,8 +162,116 @@ struct CaptureStatusButton: View { } } +struct CaptureView: View { + @ObservedObject var viewModel: ScreenCaptureViewModel + + var body: some View { + HStack { + VStack { + Text("Full Capture") + if let image = viewModel.capturedImage { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } else { + Text("No capture available") + .foregroundColor(.gray) + } + } + + Divider() + + VStack { + Text("Chessboard Preview") + if let image = viewModel.croppedBoardImage { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + .overlay( + HiddenCursorView() + .allowsHitTesting(true) + ) + } else { + Text("No board detected") + .foregroundColor(.gray) + } + + if viewModel.snapshotTaken { + Divider() + .padding(.vertical) + + Text("Latest Snapshot") + if let snapshot = viewModel.latestSnapshot { + Image(nsImage: snapshot) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + .padding() + .background(Color.black.opacity(0.1)) + .cornerRadius(8) + } + } + } + } + .padding() + } +} + +struct AnalysisView: View { + @ObservedObject var viewModel: ScreenCaptureViewModel + + var body: some View { + VStack { + if let position = viewModel.currentPosition { + HStack(alignment: .top, spacing: 20) { + VStack(alignment: .leading) { + Text("Current Position") + .font(.headline) + ChessboardView(position: position) + .padding() + .background(Color.white) + .cornerRadius(8) + .shadow(radius: 2) + + AnalysisStatusView( + isAnalyzing: viewModel.isAnalyzing, + confidence: viewModel.recognitionConfidence + ) + } + + VStack(alignment: .leading) { + Text("Position Details") + .font(.headline) + Text("FEN: \(position.fen)") + .font(.system(.body, design: .monospaced)) + .padding(.vertical) + + if viewModel.captureError == .recognitionFailed { + Text("Recognition Error") + .foregroundColor(.red) + .padding() + } + } + .padding() + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + } + } else { + Text("No position detected") + .foregroundColor(.gray) + } + + Spacer() + } + .padding() + } +} + struct ContentView: View { @StateObject private var viewModel = ScreenCaptureViewModel() + @State private var selectedTab = 0 var body: some View { VStack { @@ -110,11 +296,9 @@ struct ContentView: View { .disabled(!viewModel.isCapturing || !viewModel.isBoardDetected) } .onAppear { - // Start monitoring for chess boards when view appears viewModel.startMonitoring() } .onDisappear { - // Stop monitoring when view disappears viewModel.stopMonitoring() } @@ -125,61 +309,22 @@ struct ContentView: View { .padding() } - // Image display - HStack { - VStack { - Text("Full Capture") - if let image = viewModel.capturedImage { - Image(nsImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: 400) - } else { - Text("No capture available") - .foregroundColor(.gray) + // Main content area with tabs + TabView(selection: $selectedTab) { + CaptureView(viewModel: viewModel) + .tabItem { + Label("Capture", systemImage: "camera") } - } + .tag(0) - Divider() - - VStack { - Text("Chessboard Preview") - if let image = viewModel.croppedBoardImage { - Image(nsImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: 400) - .overlay( - HiddenCursorView() - .allowsHitTesting(true) - ) - } else { - Text("No board detected") - .foregroundColor(.gray) + AnalysisView(viewModel: viewModel) + .tabItem { + Label("Analysis", systemImage: "magnifyingglass") } - - if viewModel.snapshotTaken { - Divider() - .padding(.vertical) - - Text("Latest Snapshot") - if let snapshot = viewModel.latestSnapshot { - Image(nsImage: snapshot) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: 400) - .padding() - .background(Color.black.opacity(0.1)) - .cornerRadius(8) - } - } - } + .tag(1) } - .padding() - - Spacer() } - .frame(minWidth: 800, minHeight: 600) + .frame(minWidth: 1000, minHeight: 700) } } diff --git a/ChessPrism/ChessPrism/Models/ChessPosition.swift b/ChessPrism/ChessPrism/Models/ChessPosition.swift new file mode 100644 index 0000000..c026f33 --- /dev/null +++ b/ChessPrism/ChessPrism/Models/ChessPosition.swift @@ -0,0 +1,274 @@ +import Foundation + +/// Represents a chess piece color +enum PieceColor: String { + case white + case black +} + +/// Represents a chess piece type +enum PieceType: String { + case pawn + case knight + case bishop + case rook + case queen + case king + + /// FEN notation for the piece + var fenSymbol: String { + switch self { + case .pawn: return "p" + case .knight: return "n" + case .bishop: return "b" + case .rook: return "r" + case .queen: return "q" + case .king: return "k" + } + } +} + +/// Represents a chess piece with its type and color +struct ChessPiece: Equatable { + let type: PieceType + let color: PieceColor + + /// FEN notation for the piece (uppercase for white, lowercase for black) + var fenSymbol: String { + let symbol = type.fenSymbol + return color == .white ? symbol.uppercased() : symbol + } +} + +/// Represents a position on the chess board +struct BoardPosition: Equatable { + let file: Int // 0-7 for a-h + let rank: Int // 0-7 for 1-8 + + /// Initialize from algebraic notation (e.g., "e4") + init?(algebraic: String) { + guard algebraic.count == 2, + let file = algebraic.first?.asciiValue, + let rank = algebraic.last?.wholeNumberValue, + file >= UInt8(ascii: "a"), file <= UInt8(ascii: "h"), + rank >= 1, rank <= 8 else { + return nil + } + + self.file = Int(file - UInt8(ascii: "a")) + self.rank = rank - 1 + } + + /// Convert to algebraic notation + var algebraic: String { + let fileChar = Character(UnicodeScalar(UInt8(ascii: "a") + UInt8(file))) + return "\(fileChar)\(rank + 1)" + } + + /// Validate if the position is within bounds + var isValid: Bool { + file >= 0 && file < 8 && rank >= 0 && rank < 8 + } +} + +/// Represents a complete chess position +struct ChessPosition { + /// 8x8 grid representing the board state, nil means empty square + private var board: [[ChessPiece?]] + + /// Initialize an empty board + init() { + board = Array(repeating: Array(repeating: nil, count: 8), count: 8) + } + + /// Initialize from FEN string + init?(fen: String) { + self.init() + + let components = fen.components(separatedBy: " ") + guard components.count >= 1 else { return nil } + + let ranks = components[0].components(separatedBy: "/") + guard ranks.count == 8 else { return nil } + + for (rankIndex, rank) in ranks.enumerated() { + var fileIndex = 0 + + for char in rank { + if let number = Int(String(char)) { + fileIndex += number + } else { + guard fileIndex < 8 else { return nil } + + let color: PieceColor = char.isUppercase ? .white : .black + let lowerChar = char.lowercased() + + guard let type = pieceTypeFromFen(String(lowerChar)) else { return nil } + + board[7 - rankIndex][fileIndex] = ChessPiece(type: type, color: color) + fileIndex += 1 + } + } + + guard fileIndex == 8 else { return nil } + } + } + + /// Convert position to FEN string (piece placement only) + var fen: String { + var result = "" + + for rankIndex in (0...7).reversed() { + var emptyCount = 0 + + for fileIndex in 0...7 { + if let piece = board[rankIndex][fileIndex] { + if emptyCount > 0 { + result += String(emptyCount) + emptyCount = 0 + } + result += piece.fenSymbol + } else { + emptyCount += 1 + } + } + + if emptyCount > 0 { + result += String(emptyCount) + } + + if rankIndex > 0 { + result += "/" + } + } + + return result + } + + /// Get piece at position + subscript(position: BoardPosition) -> ChessPiece? { + get { + guard position.isValid else { return nil } + return board[position.rank][position.file] + } + set { + guard position.isValid else { return } + board[position.rank][position.file] = newValue + } + } + + /// Get piece at algebraic position + subscript(algebraic: String) -> ChessPiece? { + get { + guard let position = BoardPosition(algebraic: algebraic) else { return nil } + return self[position] + } + set { + guard let position = BoardPosition(algebraic: algebraic) else { return } + self[position] = newValue + } + } + + /// Validate if the position is legal + var isValid: Bool { + print("\n=== VALIDATING CHESS POSITION ===") + var whitePieces = [PieceType: Int]() + var blackPieces = [PieceType: Int]() + + // Count all pieces + for rank in 0...7 { + for file in 0...7 { + if let piece = board[rank][file] { + if piece.color == .white { + whitePieces[piece.type, default: 0] += 1 + } else { + blackPieces[piece.type, default: 0] += 1 + } + + // Check pawns on invalid ranks + if piece.type == .pawn && (rank == 0 || rank == 7) { + print("ERROR: Pawn found on first/last rank") + return false + } + } + } + } + + // Print piece counts + print("White pieces:") + for (type, count) in whitePieces { + print("- \(type): \(count)") + } + print("Black pieces:") + for (type, count) in blackPieces { + print("- \(type): \(count)") + } + + // Validate piece counts + let whiteTotal = whitePieces.values.reduce(0, +) + let blackTotal = blackPieces.values.reduce(0, +) + + if whiteTotal > 16 { + print("ERROR: Too many white pieces (\(whiteTotal))") + return false + } + if blackTotal > 16 { + print("ERROR: Too many black pieces (\(blackTotal))") + return false + } + + // Validate kings + if whitePieces[.king] ?? 0 != 1 { + print("ERROR: Invalid number of white kings (\(whitePieces[.king] ?? 0))") + return false + } + if blackPieces[.king] ?? 0 != 1 { + print("ERROR: Invalid number of black kings (\(blackPieces[.king] ?? 0))") + return false + } + + // Validate pawns + if whitePieces[.pawn] ?? 0 > 8 { + print("ERROR: Too many white pawns (\(whitePieces[.pawn] ?? 0))") + return false + } + if blackPieces[.pawn] ?? 0 > 8 { + print("ERROR: Too many black pawns (\(blackPieces[.pawn] ?? 0))") + return false + } + + // Validate other pieces + for pieceType in [PieceType.queen, .rook, .bishop, .knight] { + if whitePieces[pieceType] ?? 0 > 2 { + print("ERROR: Too many white \(pieceType)s (\(whitePieces[pieceType] ?? 0))") + return false + } + if blackPieces[pieceType] ?? 0 > 2 { + print("ERROR: Too many black \(pieceType)s (\(blackPieces[pieceType] ?? 0))") + return false + } + } + + print("Position validation successful") + return true + } + + /// Helper function to convert FEN piece symbol to PieceType + private func pieceTypeFromFen(_ symbol: String) -> PieceType? { + switch symbol { + case "p": return .pawn + case "n": return .knight + case "b": return .bishop + case "r": return .rook + case "q": return .queen + case "k": return .king + default: return nil + } + } + + /// Initialize with the standard starting position + static var startingPosition: ChessPosition { + // swiftlint:disable:next force_unwrapping + ChessPosition(fen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR")! + } +} diff --git a/ChessPrism/ChessPrism/Recognition/PieceRecognizer.swift b/ChessPrism/ChessPrism/Recognition/PieceRecognizer.swift new file mode 100644 index 0000000..4dceafd --- /dev/null +++ b/ChessPrism/ChessPrism/Recognition/PieceRecognizer.swift @@ -0,0 +1,397 @@ +import Foundation +import Vision +import CoreML +import CoreImage + +/// Errors that can occur during piece recognition +enum PieceRecognitionError: Error { + case invalidImageDimensions + case modelLoadError + case recognitionFailed(String) + case lowConfidence + case invalidInput + case invalidPosition(String) +} + +/// A class responsible for recognizing chess pieces from images +final class PieceRecognizer { + // MARK: - Properties + + /// Vision model for piece classification + private let vnModel: VNCoreMLModel + + /// Shared CIContext for image processing + private static let ciContext = CIContext() + + /// Piece counts for validation + private var whitePieceCount: [PieceType: Int] = [:] + private var blackPieceCount: [PieceType: Int] = [:] + + // MARK: - Initialization + + init() throws { + print("=== INITIALIZING PIECE RECOGNIZER ===") + + let bundle = Bundle.main + + // Load model from bundle + guard let modelURL = bundle.url(forResource: "ChessPieceClassifier", withExtension: "mlmodelc") else { + print("ERROR: Model not found in bundle at \(bundle.bundlePath)") + throw PieceRecognitionError.modelLoadError + } + + do { + let config = MLModelConfiguration() + config.computeUnits = .all + let model = try MLModel(contentsOf: modelURL, configuration: config) + self.vnModel = try VNCoreMLModel(for: model) + print("Model loaded successfully from: \(modelURL.path)") + } catch { + print("ERROR: Failed to load model - \(error)") + throw PieceRecognitionError.modelLoadError + } + + resetPieceCounts() + } + + // MARK: - Recognition Methods + + /// Reset piece counts for new position + func resetPieceCounts() { + whitePieceCount = [ + .king: 0, + .queen: 0, + .rook: 0, + .bishop: 0, + .knight: 0, + .pawn: 0 + ] + blackPieceCount = [ + .king: 0, + .queen: 0, + .rook: 0, + .bishop: 0, + .knight: 0, + .pawn: 0 + ] + } + + /// Validate piece counts and ensure the position is legal + func validatePieceCounts() throws { + var errors: [String] = [] + + // Check white pieces + if whitePieceCount[.king] != 1 { + let count = whitePieceCount[.king] ?? 0 + errors.append("Invalid white king count: \(count)") + } + if let count = whitePieceCount[.queen], count > 1 { + errors.append("Too many white queens: \(count)") + } + if let count = whitePieceCount[.rook], count > 2 { + errors.append("Too many white rooks: \(count)") + } + if let count = whitePieceCount[.bishop], count > 2 { + errors.append("Too many white bishops: \(count)") + } + if let count = whitePieceCount[.knight], count > 2 { + errors.append("Too many white knights: \(count)") + } + if let count = whitePieceCount[.pawn], count > 8 { + errors.append("Too many white pawns: \(count)") + } + + // Check black pieces + if blackPieceCount[.king] ?? 0 != 1 { + let count = blackPieceCount[.king] ?? 0 + errors.append("Invalid black king count: \(count)") + } + if let count = blackPieceCount[.queen], count > 1 { + errors.append("Too many black queens: \(count)") + } + if let count = blackPieceCount[.rook], count > 2 { + errors.append("Too many black rooks: \(count)") + } + if let count = blackPieceCount[.bishop], count > 2 { + errors.append("Too many black bishops: \(count)") + } + if let count = blackPieceCount[.knight], count > 2 { + errors.append("Too many black knights: \(count)") + } + if let count = blackPieceCount[.pawn], count > 8 { + errors.append("Too many black pawns: \(count)") + } + + if !errors.isEmpty { + throw PieceRecognitionError.invalidPosition(errors.joined(separator: ", ")) + } + } + + /// Update piece count + private func updatePieceCount(piece: ChessPiece) { + if piece.color == .white { + whitePieceCount[piece.type] = (whitePieceCount[piece.type] ?? 0) + 1 + } else { + blackPieceCount[piece.type] = (blackPieceCount[piece.type] ?? 0) + 1 + } + } + + /// Check if adding this piece would exceed limits + private func wouldExceedLimits(_ piece: ChessPiece) -> Bool { + let count = piece.color == .white ? whitePieceCount[piece.type] ?? 0 : blackPieceCount[piece.type] ?? 0 + switch piece.type { + case .king: return count >= 1 + case .queen: return count >= 1 + case .rook, .bishop, .knight: return count >= 2 + case .pawn: return count >= 8 + } + } + + /// Recognize a chess piece from a square image + /// - Parameters: + /// - image: CGImage of the chess square + /// - completion: Callback with result (ChessPiece if recognized, nil if empty) + /// - Throws: PieceRecognitionError + func recognizePiece(from image: CGImage) async throws -> ChessPiece? { + // Validate image dimensions + guard image.width > 0, image.height > 0, + abs(1 - Float(image.width) / Float(image.height)) < 0.1 else { + print("ERROR: Invalid square dimensions \(image.width)x\(image.height)") + throw PieceRecognitionError.invalidImageDimensions + } + + let handler = VNImageRequestHandler(cgImage: image) + var classificationResults: [VNClassificationObservation]? + var classificationError: Error? + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let request = VNCoreMLRequest(model: vnModel) { request, error in + if let error = error { + classificationError = error + continuation.resume(throwing: error) + return + } + classificationResults = request.results as? [VNClassificationObservation] + continuation.resume() + } + request.imageCropAndScaleOption = .centerCrop + + do { + try handler.perform([request]) + } catch { + continuation.resume(throwing: error) + } + } + + if let error = classificationError { + print("ERROR: Classification failed - \(error)") + throw PieceRecognitionError.recognitionFailed(error.localizedDescription) + } + + guard let results = classificationResults, + let topResult = results.first else { + print("ERROR: No classification results") + throw PieceRecognitionError.recognitionFailed("No results") + } + + // Print all results to help diagnose recognition issues + if let pos = currentPosition { + print("\nClassification results for \(String(describing: pos)):") + } else { + print("\nClassification results:") + } + for result in results.prefix(3) { + print("- \(result.identifier): \(result.confidence)") + } + + // Check for empty squares + if topResult.identifier == "empty_dark" || topResult.identifier == "empty_light" { + if topResult.confidence > 0.9 { + if let pos = currentPosition { + print("\nEmpty square confirmed at \(String(describing: pos))") + } + return nil + } + } + + // Get confidence ratio between top predictions + let secondBestConfidence = results.count > 1 ? results[1].confidence : 0 + let confidenceRatio = topResult.confidence / (secondBestConfidence + Float.ulpOfOne) + + // Adjust confidence based on position-specific knowledge + let adjustedConfidence = adjustConfidence(topResult.confidence, + for: topResult.identifier, + at: currentPosition) + + // Much stricter piece recognition: + // 1. Must have very high confidence (>0.98) + // 2. Must have strong separation from second best (>5.0 ratio) + // 3. Must make sense for position + // 4. Must not exceed piece limits + if adjustedConfidence > 0.98 && confidenceRatio > 5.0 { + if let piece = try? createPiece(from: topResult.identifier) { + if isValidPieceForPosition(piece, at: currentPosition) && !wouldExceedLimits(piece) { + updatePieceCount(piece: piece) + return piece + } + } + } + + // Log classification details + if let pos = currentPosition { + print("\nClassification rejected at \(String(describing: pos)):") + } + print("Top result: \(topResult.identifier) (\(topResult.confidence))") + if results.count > 1 { + print("Second best: \(results[1].identifier) (\(results[1].confidence))") + print("Confidence ratio: \(confidenceRatio)") + } + print("Adjusted confidence: \(adjustedConfidence)") + + return nil // Default to empty for unclear cases + } + + /// Position where this piece is being recognized + var currentPosition: BoardPosition? + + /// Adjust confidence based on position-specific knowledge + private func adjustConfidence(_ confidence: Float, for identifier: String, at position: BoardPosition?) -> Float { + guard let position = currentPosition else { return confidence } + + // Parse the piece info + let components = identifier.split(separator: "_") + guard components.count == 2, + let color = PieceColor(rawValue: String(components[0])), + let type = PieceType(rawValue: String(components[1])) else { + return confidence + } + + var adjustment: Float = 0.0 + + // Back rank pieces are more likely to be correct + if position.isBackRank(for: color) { + // Corners should be rooks + if type == .rook && position.isEdgeFile { + adjustment += 0.1 + } + // Next to corners should be knights + if type == .knight && (position.file == 1 || position.file == 6) { + adjustment += 0.1 + } + // Next to knights should be bishops + if type == .bishop && (position.file == 2 || position.file == 5) { + adjustment += 0.1 + } + // Center should be king/queen + if (type == .king || type == .queen) && position.isCenterFile { + adjustment += 0.1 + } + } + + // Pawns are more likely on their starting ranks + if type == .pawn { + if (color == .white && position.rank == 1) || + (color == .black && position.rank == 6) { + adjustment += 0.1 + } + } + + return min(1.0, confidence + adjustment) + } + + /// Validate if a piece makes sense for its position + private func isValidPieceForPosition(_ piece: ChessPiece, at position: BoardPosition?) -> Bool { + guard let pos = position else { return true } + + // Basic position validation + switch piece.type { + case .king: + // Kings can't be on the first or last rank of opponent's side + if piece.color == .white && pos.rank == 7 { return false } + if piece.color == .black && pos.rank == 0 { return false } + + case .pawn: + // Pawns can't be on first or last rank + if pos.rank == 0 || pos.rank == 7 { return false } + // White pawns can't be behind their starting rank + if piece.color == .white && pos.rank > 6 { return false } + // Black pawns can't be behind their starting rank + if piece.color == .black && pos.rank < 1 { return false } + + default: + // Other pieces can move freely + break + } + + return true + } + + /// Helper to create a chess piece from a classification label + private func createPiece(from identifier: String) throws -> ChessPiece { + // Validate it's not an empty square + guard !identifier.starts(with: "empty_") else { + print("ERROR: Cannot create piece from empty square label: \(identifier)") + throw PieceRecognitionError.invalidInput + } + + let components = identifier.split(separator: "_") + guard components.count == 2, + let color = PieceColor(rawValue: String(components[0])), + let type = PieceType(rawValue: String(components[1])) else { + print("ERROR: Invalid piece label format: \(identifier)") + throw PieceRecognitionError.invalidInput + } + return ChessPiece(type: type, color: color) + } + + /// Preprocess an image for recognition + /// - Parameter image: Input CGImage + /// - Returns: Preprocessed CGImage + func preprocessImage(_ image: CGImage) throws -> CGImage { + let ciImage = CIImage(cgImage: image) + + // Apply preprocessing filters + let processed = ciImage + .applyingFilter("CIColorControls", parameters: [ + kCIInputContrastKey: 1.1, + kCIInputBrightnessKey: 0.0, + kCIInputSaturationKey: 1.1 + ]) + .applyingFilter("CIUnsharpMask", parameters: [ + kCIInputRadiusKey: 1.0, + kCIInputIntensityKey: 0.5 + ]) + + // Convert back to CGImage + guard let outputImage = Self.ciContext.createCGImage(processed, from: processed.extent) else { + throw PieceRecognitionError.invalidInput + } + + return outputImage + } +} + +// MARK: - BoardPosition Extensions + +extension BoardPosition { + /// Initialize from file and rank indices + init(file: Int, rank: Int) { + self.file = file + self.rank = rank + } + + /// Whether this position is on the edge of the board (files a or h) + var isEdgeFile: Bool { + return file == 0 || file == 7 + } + + /// Whether this position is on the back rank for the given color + func isBackRank(for color: PieceColor) -> Bool { + return (color == .white && rank == 0) || (color == .black && rank == 7) + } + + /// Whether this position is in the center files (d or e) + var isCenterFile: Bool { + return file == 3 || file == 4 + } +} diff --git a/ChessPrism/ChessPrism/ScreenCaptureViewModel.swift b/ChessPrism/ChessPrism/ScreenCaptureViewModel.swift index bf943ef..41e8337 100644 --- a/ChessPrism/ChessPrism/ScreenCaptureViewModel.swift +++ b/ChessPrism/ChessPrism/ScreenCaptureViewModel.swift @@ -13,10 +13,14 @@ class ScreenCaptureViewModel: ObservableObject { @Published var isAutoCapturing = true // Default to auto-capture mode @Published var snapshotTaken = false // Track if snapshot was taken @Published var latestSnapshot: NSImage? // Make snapshot accessible to view + @Published var currentPosition: ChessPosition? // Current chess position + @Published var recognitionConfidence: Double = 0.0 // Recognition confidence + @Published var isAnalyzing = false // Track analysis state private let captureManager = ScreenCapture() // For actual capture private let monitorManager = ScreenCapture() // For monitoring - private let boardDetector = BoardDetector() + private let pieceRecognizer: PieceRecognizer + private let boardDetector: BoardDetector private var captureTask: Task? private var monitorTask: Task? // Share CIContext to avoid creating too many Metal command queues @@ -31,6 +35,8 @@ class ScreenCaptureViewModel: ObservableObject { case boardDetectionFailed case noBoardDetected case snapshotFailed + case recognitionFailed + case invalidPosition var errorDescription: String? { switch self { @@ -42,40 +48,61 @@ class ScreenCaptureViewModel: ObservableObject { return "No chess board detected" case .snapshotFailed: return "Failed to take snapshot" + case .recognitionFailed: + return "Failed to recognize pieces" + case .invalidPosition: + return "Invalid chess position detected" } } } - func takeSnapshot() async { - // Stop current capture - try? await captureManager.stopCapture() - - // Start new capture without cursor + init() { + // Initialize piece recognizer do { + pieceRecognizer = try PieceRecognizer() + boardDetector = BoardDetector(pieceRecognizer: pieceRecognizer) + } catch { + fatalError("Failed to initialize piece recognizer: \(error)") + } + } + + func takeSnapshot() async { + print("=== SCAN BUTTON PRESSED ===") + do { + try await captureManager.stopCapture() try await captureManager.startCapture(excludeCursor: true) - // Wait a brief moment for the capture to stabilize try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds if let currentImage = captureManager.getCurrentImage() { - // Process the image to get the cropped board - try await processImage(currentImage) + print("Captured image: \(currentImage.size)") + try await processImage(currentImage, analyzePosition: true) - // Store the cropped board as snapshot if let boardImage = croppedBoardImage { latestSnapshot = boardImage snapshotTaken = true + + if let position = currentPosition { + print("Successfully detected position") + } else { + print("ERROR: Failed to detect position") + captureError = .recognitionFailed + } } else { + print("ERROR: Failed to crop board image") captureError = .snapshotFailed + currentPosition = nil } } else { + print("ERROR: Failed to capture image") captureError = .snapshotFailed + currentPosition = nil } - // Restart normal capture try await captureManager.startCapture(excludeCursor: false) } catch { + print("ERROR: Snapshot failed - \(error)") captureError = .snapshotFailed - // Ensure we restart normal capture even if snapshot fails + currentPosition = nil try? await captureManager.startCapture(excludeCursor: false) } } @@ -137,6 +164,8 @@ class ScreenCaptureViewModel: ObservableObject { captureError = nil snapshotTaken = false latestSnapshot = nil + currentPosition = nil + recognitionConfidence = 0.0 do { try await captureManager.startCapture() @@ -150,7 +179,7 @@ class ScreenCaptureViewModel: ObservableObject { captureLoop: while !Task.isCancelled { do { if let image = captureManager.getCurrentImage() { - try await processImage(image) + try await processImage(image, analyzePosition: false) } try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds } catch is CancellationError { @@ -182,6 +211,8 @@ class ScreenCaptureViewModel: ObservableObject { isBoardDetected = false snapshotTaken = false latestSnapshot = nil + currentPosition = nil + recognitionConfidence = 0.0 // Clean up capture session Task { @@ -189,29 +220,47 @@ class ScreenCaptureViewModel: ObservableObject { } } - private func processImage(_ image: NSImage) async throws { - // Convert to CIImage for processing + private func processImage(_ image: NSImage, analyzePosition: Bool = false) async throws { guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + print("ERROR: Failed to convert image for processing") throw CaptureError.boardDetectionFailed } let ciImage = CIImage(cgImage: cgImage) - // Detect board if let boardRect = boardDetector.detectBoard(in: ciImage) { - // Board detected isBoardDetected = true self.detectedBoardRect = boardRect - // Crop board image let croppedImage = ciImage.cropped(to: boardRect) updateImages(ciImage: ciImage, croppedImage: croppedImage) + + if analyzePosition && !isAnalyzing { + isAnalyzing = true + do { + print("Analyzing board position...") + let position = try await boardDetector.analyzeBoard(in: ciImage) + currentPosition = position + recognitionConfidence = 1.0 + isAnalyzing = false + } catch { + print("ERROR: Position analysis failed - \(error)") + isAnalyzing = false + currentPosition = nil + recognitionConfidence = 0.0 + if error is BoardDetectionError { + throw CaptureError.recognitionFailed + } else { + throw error + } + } + } } else { - // No board detected isBoardDetected = false self.detectedBoardRect = nil + currentPosition = nil + recognitionConfidence = 0.0 - // Only update the full capture image if let cgImage = context.createCGImage(ciImage, from: ciImage.extent) { self.capturedImage = NSImage(cgImage: cgImage, size: .zero) } diff --git a/cline_docs/Info.txt b/cline_docs/Info.txt index 7f03501..7f989e4 100644 --- a/cline_docs/Info.txt +++ b/cline_docs/Info.txt @@ -1,571 +1,593 @@ +=== INITIALIZING PIECE RECOGNIZER === +Model loaded successfully from: /Users/chaulmark/Library/Developer/Xcode/DerivedData/ChessPrism-eefzflqwaismgqgjoetabiujjcgr/Build/Products/Debug/ChessPrism.app/Contents/Resources/ChessPieceClassifier.mlmodelc +AddInstanceForFactory: No factory registered for id F8BB1C28-BAE8-11D6-9C31-00039315CD46 +=== SCAN BUTTON PRESSED === +Captured image: (919.0, 670.0) +Analyzing board position... +=== ANALYZING BOARD === -Command queue creation failed. Worst processes ( - { - Count = 2017; - Process = "pid 25345, ChessPrism"; - }, - { - Count = 4; - Process = "pid 1227, firefox"; - }, - { - Count = 3; - Process = "pid 579, WindowServer"; - }, - { - Count = 3; - Process = "pid 1926, Creative Cloud U"; - }, - { - Count = 2; - Process = "pid 1754, Ollama Helper (G"; - }, - { - Count = 2; - Process = "pid 1928, LogiTune Helper "; - }, - { - Count = 2; - Process = "pid 11425, Xcode"; - }, - { - Count = 1; - Process = "pid 1062, mediaanalysisd"; - }, - { - Count = 1; - Process = "pid 1101, NotificationCent"; - }, - { - Count = 1; - Process = "pid 582, loginwindow"; - }, - { - Count = 1; - Process = "pid 1242, Finder"; - }, - { - Count = 1; - Process = "pid 1100, iconservicesagen"; - }, - { - Count = 1; - Process = "pid 1237, Notes"; - }, - { - Count = 1; - Process = "pid 1222, Terminal"; - }, - { - Count = 1; - Process = "pid 1867, Docker Desktop H"; - }, - { - Count = 1; - Process = "pid 1970, Logi AI Prompt B"; - }, - { - Count = 1; - Process = "pid 1040, ControlCenter"; - }, - { - Count = 1; - Process = "pid 905, iconservicesagen"; - }, - { - Count = 1; - Process = "pid 7637, ControlCenterHel"; - }, - { - Count = 1; - Process = "pid 15435, Code Helper (GPU"; - }, - { - Count = 0; - Process = "pid 1208, avconferenced"; - }, - { - Count = 0; - Process = "pid 1228, VTDecoderXPCServ"; - }, - { - Count = 0; - Process = "pid 1248, Spotlight"; - }, - { - Count = 0; - Process = "pid 1186, replayd"; - }, - { - Count = 0; - Process = "pid 1459, naturallanguaged"; - }, - { - Count = 0; - Process = "pid 577, com.apple.cmio.r"; - }, - { - Count = 0; - Process = "pid 2216, VTEncoderXPCServ"; - }, - { - Count = 0; - Process = "pid 7667, QuickLookUIServi"; - } -) -Command queue creation failed. Worst processes ( - { - Count = 2017; - Process = "pid 25345, ChessPrism"; - }, - { - Count = 4; - Process = "pid 1227, firefox"; - }, - { - Count = 3; - Process = "pid 579, WindowServer"; - }, - { - Count = 3; - Process = "pid 1926, Creative Cloud U"; - }, - { - Count = 2; - Process = "pid 1754, Ollama Helper (G"; - }, - { - Count = 2; - Process = "pid 1928, LogiTune Helper "; - }, - { - Count = 2; - Process = "pid 11425, Xcode"; - }, - { - Count = 1; - Process = "pid 1062, mediaanalysisd"; - }, - { - Count = 1; - Process = "pid 1101, NotificationCent"; - }, - { - Count = 1; - Process = "pid 582, loginwindow"; - }, - { - Count = 1; - Process = "pid 1242, Finder"; - }, - { - Count = 1; - Process = "pid 1100, iconservicesagen"; - }, - { - Count = 1; - Process = "pid 1237, Notes"; - }, - { - Count = 1; - Process = "pid 1222, Terminal"; - }, - { - Count = 1; - Process = "pid 1867, Docker Desktop H"; - }, - { - Count = 1; - Process = "pid 1970, Logi AI Prompt B"; - }, - { - Count = 1; - Process = "pid 1040, ControlCenter"; - }, - { - Count = 1; - Process = "pid 905, iconservicesagen"; - }, - { - Count = 1; - Process = "pid 7637, ControlCenterHel"; - }, - { - Count = 1; - Process = "pid 15435, Code Helper (GPU"; - }, - { - Count = 0; - Process = "pid 1208, avconferenced"; - }, - { - Count = 0; - Process = "pid 1228, VTDecoderXPCServ"; - }, - { - Count = 0; - Process = "pid 1248, Spotlight"; - }, - { - Count = 0; - Process = "pid 1186, replayd"; - }, - { - Count = 0; - Process = "pid 1459, naturallanguaged"; - }, - { - Count = 0; - Process = "pid 577, com.apple.cmio.r"; - }, - { - Count = 0; - Process = "pid 2216, VTEncoderXPCServ"; - }, - { - Count = 0; - Process = "pid 7667, QuickLookUIServi"; - } -) -Command queue creation failed. Worst processes ( - { - Count = 2017; - Process = "pid 25345, ChessPrism"; - }, - { - Count = 4; - Process = "pid 1227, firefox"; - }, - { - Count = 3; - Process = "pid 579, WindowServer"; - }, - { - Count = 3; - Process = "pid 1926, Creative Cloud U"; - }, - { - Count = 2; - Process = "pid 1754, Ollama Helper (G"; - }, - { - Count = 2; - Process = "pid 1928, LogiTune Helper "; - }, - { - Count = 2; - Process = "pid 11425, Xcode"; - }, - { - Count = 1; - Process = "pid 1062, mediaanalysisd"; - }, - { - Count = 1; - Process = "pid 1101, NotificationCent"; - }, - { - Count = 1; - Process = "pid 582, loginwindow"; - }, - { - Count = 1; - Process = "pid 1242, Finder"; - }, - { - Count = 1; - Process = "pid 1100, iconservicesagen"; - }, - { - Count = 1; - Process = "pid 1237, Notes"; - }, - { - Count = 1; - Process = "pid 1222, Terminal"; - }, - { - Count = 1; - Process = "pid 1867, Docker Desktop H"; - }, - { - Count = 1; - Process = "pid 1970, Logi AI Prompt B"; - }, - { - Count = 1; - Process = "pid 1040, ControlCenter"; - }, - { - Count = 1; - Process = "pid 905, iconservicesagen"; - }, - { - Count = 1; - Process = "pid 7637, ControlCenterHel"; - }, - { - Count = 1; - Process = "pid 15435, Code Helper (GPU"; - }, - { - Count = 0; - Process = "pid 1208, avconferenced"; - }, - { - Count = 0; - Process = "pid 1228, VTDecoderXPCServ"; - }, - { - Count = 0; - Process = "pid 1248, Spotlight"; - }, - { - Count = 0; - Process = "pid 1186, replayd"; - }, - { - Count = 0; - Process = "pid 1459, naturallanguaged"; - }, - { - Count = 0; - Process = "pid 577, com.apple.cmio.r"; - }, - { - Count = 0; - Process = "pid 2216, VTEncoderXPCServ"; - }, - { - Count = 0; - Process = "pid 7667, QuickLookUIServi"; - } -) -Command queue creation failed. Worst processes ( - { - Count = 2017; - Process = "pid 25345, ChessPrism"; - }, - { - Count = 4; - Process = "pid 1227, firefox"; - }, - { - Count = 3; - Process = "pid 579, WindowServer"; - }, - { - Count = 3; - Process = "pid 1926, Creative Cloud U"; - }, - { - Count = 2; - Process = "pid 1754, Ollama Helper (G"; - }, - { - Count = 2; - Process = "pid 1928, LogiTune Helper "; - }, - { - Count = 2; - Process = "pid 11425, Xcode"; - }, - { - Count = 1; - Process = "pid 1062, mediaanalysisd"; - }, - { - Count = 1; - Process = "pid 1101, NotificationCent"; - }, - { - Count = 1; - Process = "pid 582, loginwindow"; - }, - { - Count = 1; - Process = "pid 1242, Finder"; - }, - { - Count = 1; - Process = "pid 1100, iconservicesagen"; - }, - { - Count = 1; - Process = "pid 1237, Notes"; - }, - { - Count = 1; - Process = "pid 1222, Terminal"; - }, - { - Count = 1; - Process = "pid 1867, Docker Desktop H"; - }, - { - Count = 1; - Process = "pid 1970, Logi AI Prompt B"; - }, - { - Count = 1; - Process = "pid 1040, ControlCenter"; - }, - { - Count = 1; - Process = "pid 905, iconservicesagen"; - }, - { - Count = 1; - Process = "pid 7637, ControlCenterHel"; - }, - { - Count = 1; - Process = "pid 15435, Code Helper (GPU"; - }, - { - Count = 0; - Process = "pid 1208, avconferenced"; - }, - { - Count = 0; - Process = "pid 1228, VTDecoderXPCServ"; - }, - { - Count = 0; - Process = "pid 1248, Spotlight"; - }, - { - Count = 0; - Process = "pid 1186, replayd"; - }, - { - Count = 0; - Process = "pid 1459, naturallanguaged"; - }, - { - Count = 0; - Process = "pid 577, com.apple.cmio.r"; - }, - { - Count = 0; - Process = "pid 2216, VTEncoderXPCServ"; - }, - { - Count = 0; - Process = "pid 7667, QuickLookUIServi"; - } -) -Command queue creation failed. Worst processes ( - { - Count = 2017; - Process = "pid 25345, ChessPrism"; - }, - { - Count = 4; - Process = "pid 1227, firefox"; - }, - { - Count = 3; - Process = "pid 579, WindowServer"; - }, - { - Count = 3; - Process = "pid 1926, Creative Cloud U"; - }, - { - Count = 2; - Process = "pid 1754, Ollama Helper (G"; - }, - { - Count = 2; - Process = "pid 1928, LogiTune Helper "; - }, - { - Count = 2; - Process = "pid 11425, Xcode"; - }, - { - Count = 1; - Process = "pid 1062, mediaanalysisd"; - }, - { - Count = 1; - Process = "pid 1101, NotificationCent"; - }, - { - Count = 1; - Process = "pid 582, loginwindow"; - }, - { - Count = 1; - Process = "pid 1242, Finder"; - }, - { - Count = 1; - Process = "pid 1100, iconservicesagen"; - }, - { - Count = 1; - Process = "pid 1237, Notes"; - }, - { - Count = 1; - Process = "pid 1222, Terminal"; - }, - { - Count = 1; - Process = "pid 1867, Docker Desktop H"; - }, - { - Count = 1; - Process = "pid 1970, Logi AI Prompt B"; - }, - { - Count = 1; - Process = "pid 1040, ControlCenter"; - }, - { - Count = 1; - Process = "pid 905, iconservicesagen"; - }, - { - Count = 1; - Process = "pid 7637, ControlCenterHel"; - }, - { - Count = 1; - Process = "pid 15435, Code Helper (GPU"; - }, - { - Count = 0; - Process = "pid 1208, avconferenced"; - }, - { - Count = 0; - Process = "pid 1228, VTDecoderXPCServ"; - }, - { - Count = 0; - Process = "pid 1248, Spotlight"; - }, - { - Count = 0; - Process = "pid 1186, replayd"; - }, - { - Count = 0; - Process = "pid 1459, naturallanguaged"; - }, - { - Count = 0; - Process = "pid 577, com.apple.cmio.r"; - }, - { - Count = 0; - Process = "pid 2216, VTEncoderXPCServ"; - }, - { - Count = 0; - Process = "pid 7667, QuickLookUIServi"; - } -) \ No newline at end of file +Classification results for BoardPosition(file: 0, rank: 0): +- white_rook: 0.9999322 +- black_rook: 6.774748e-05 +- white_king: 7.3981004e-09 + +Classification results for BoardPosition(file: 1, rank: 0): +- white_knight: 1.0 +- black_knight: 1.1934526e-17 +- white_bishop: 1.2330606e-24 + +Classification results for BoardPosition(file: 2, rank: 0): +- black_knight: 0.9997147 +- black_rook: 0.00027883475 +- black_bishop: 5.89228e-06 + +Classification results for BoardPosition(file: 3, rank: 0): +- white_king: 0.9983813 +- white_rook: 0.0010669202 +- white_knight: 0.00041843182 + +Classification results for BoardPosition(file: 4, rank: 0): +- white_king: 1.0 +- black_king: 6.379774e-10 +- black_bishop: 1.0737971e-14 + +Classification rejected at BoardPosition(file: 4, rank: 0): +Top result: white_king (1.0) +Second best: black_king (6.379774e-10) +Confidence ratio: 8343953.0 +Adjusted confidence: 1.0 + +Classification results for BoardPosition(file: 5, rank: 0): +- white_rook: 0.58311313 +- white_king: 0.4168078 +- black_bishop: 7.6859214e-05 + +Classification rejected at BoardPosition(file: 5, rank: 0): +Top result: white_rook (0.58311313) +Second best: white_king (0.4168078) +Confidence ratio: 1.3989973 +Adjusted confidence: 0.58311313 + +Classification results for BoardPosition(file: 6, rank: 0): +- white_knight: 1.0 +- black_knight: 1.3012843e-15 +- white_bishop: 4.952984e-22 + +Classification results for BoardPosition(file: 7, rank: 0): +- white_rook: 0.9992894 +- black_rook: 0.0007105772 +- white_king: 5.2344448e-11 + +Classification results for BoardPosition(file: 0, rank: 1): +- white_pawn: 1.0 +- white_king: 7.0033545e-12 +- black_pawn: 1.2942099e-12 + +Classification results for BoardPosition(file: 1, rank: 1): +- white_pawn: 1.0 +- black_pawn: 1.6859661e-13 +- black_bishop: 2.603961e-19 + +Classification results for BoardPosition(file: 2, rank: 1): +- white_rook: 0.89660114 +- white_king: 0.086419925 +- white_knight: 0.016850794 + +Classification rejected at BoardPosition(file: 2, rank: 1): +Top result: white_rook (0.89660114) +Second best: white_king (0.086419925) +Confidence ratio: 10.374921 +Adjusted confidence: 0.89660114 + +Classification results for BoardPosition(file: 3, rank: 1): +- black_knight: 0.9998835 +- black_pawn: 7.848534e-05 +- black_rook: 3.68113e-05 + +Classification results for BoardPosition(file: 4, rank: 1): +- white_king: 0.7772867 +- white_rook: 0.21319194 +- white_knight: 0.008732727 + +Classification rejected at BoardPosition(file: 4, rank: 1): +Top result: white_king (0.7772867) +Second best: white_rook (0.21319194) +Confidence ratio: 3.6459458 +Adjusted confidence: 0.7772867 + +Classification results for BoardPosition(file: 5, rank: 1): +- white_pawn: 1.0 +- black_pawn: 1.3066745e-13 +- black_bishop: 3.320688e-20 + +Classification results for BoardPosition(file: 6, rank: 1): +- white_pawn: 1.0 +- black_pawn: 2.7381055e-16 +- black_bishop: 1.1745517e-21 + +Classification results for BoardPosition(file: 7, rank: 1): +- white_pawn: 1.0 +- black_pawn: 7.737975e-12 +- black_bishop: 1.797723e-18 + +Classification results for BoardPosition(file: 0, rank: 2): +- black_rook: 0.99889785 +- black_knight: 0.0009475333 +- black_bishop: 0.00013781422 + +Classification results for BoardPosition(file: 1, rank: 2): +- white_bishop: 1.0 +- white_knight: 5.039787e-13 +- black_bishop: 2.1238877e-13 + +Classification results for BoardPosition(file: 2, rank: 2): +- white_pawn: 1.0 +- black_pawn: 5.417135e-12 +- black_bishop: 6.220996e-21 + +Classification results for BoardPosition(file: 3, rank: 2): +- black_knight: 0.83722544 +- white_knight: 0.1627746 +- black_bishop: 2.8136882e-19 + +Classification rejected at BoardPosition(file: 3, rank: 2): +Top result: black_knight (0.83722544) +Second best: white_knight (0.1627746) +Confidence ratio: 5.143461 +Adjusted confidence: 0.83722544 + +Classification results for BoardPosition(file: 4, rank: 2): +- black_knight: 0.81991595 +- black_rook: 0.17813577 +- black_pawn: 0.0017957124 + +Classification rejected at BoardPosition(file: 4, rank: 2): +Top result: black_knight (0.81991595) +Second best: black_rook (0.17813577) +Confidence ratio: 4.6027555 +Adjusted confidence: 0.81991595 + +Classification results for BoardPosition(file: 5, rank: 2): +- white_rook: 0.8535699 +- white_king: 0.124892734 +- white_knight: 0.02131261 + +Classification rejected at BoardPosition(file: 5, rank: 2): +Top result: white_rook (0.8535699) +Second best: white_king (0.124892734) +Confidence ratio: 6.834418 +Adjusted confidence: 0.8535699 + +Classification results for BoardPosition(file: 6, rank: 2): +- black_knight: 0.94812983 +- black_rook: 0.051835023 +- black_pawn: 3.4919147e-05 + +Classification rejected at BoardPosition(file: 6, rank: 2): +Top result: black_knight (0.94812983) +Second best: black_rook (0.051835023) +Confidence ratio: 18.291256 +Adjusted confidence: 0.94812983 + +Classification results for BoardPosition(file: 7, rank: 2): +- white_rook: 0.9989638 +- white_knight: 0.0006915265 +- black_knight: 0.00022520062 + +Classification rejected at BoardPosition(file: 7, rank: 2): +Top result: white_rook (0.9989638) +Second best: white_knight (0.0006915265) +Confidence ratio: 1444.3287 +Adjusted confidence: 0.9989638 + +Classification results for BoardPosition(file: 0, rank: 3): +- white_rook: 0.99999315 +- white_king: 5.72426e-06 +- white_knight: 1.0547665e-06 + +Classification rejected at BoardPosition(file: 0, rank: 3): +Top result: white_rook (0.99999315) +Second best: white_king (5.72426e-06) +Confidence ratio: 171130.05 +Adjusted confidence: 0.99999315 + +Classification results for BoardPosition(file: 1, rank: 3): +- black_knight: 0.9960707 +- black_rook: 0.0038440374 +- black_pawn: 7.4432515e-05 + +Classification rejected at BoardPosition(file: 1, rank: 3): +Top result: black_knight (0.9960707) +Second best: black_rook (0.0038440374) +Confidence ratio: 259.1129 +Adjusted confidence: 0.9960707 + +Classification results for BoardPosition(file: 2, rank: 3): +- white_rook: 0.9182533 +- white_knight: 0.067995325 +- white_king: 0.01360172 + +Classification rejected at BoardPosition(file: 2, rank: 3): +Top result: white_rook (0.9182533) +Second best: white_knight (0.067995325) +Confidence ratio: 13.50463 +Adjusted confidence: 0.9182533 + +Classification results for BoardPosition(file: 3, rank: 3): +- white_bishop: 1.0 +- white_knight: 1.1954664e-12 +- black_bishop: 7.699053e-15 + +Classification results for BoardPosition(file: 4, rank: 3): +- white_rook: 0.7775835 +- white_king: 0.1671592 +- white_knight: 0.055232733 + +Classification rejected at BoardPosition(file: 4, rank: 3): +Top result: white_rook (0.7775835) +Second best: white_king (0.1671592) +Confidence ratio: 4.6517506 +Adjusted confidence: 0.7775835 + +Classification results for BoardPosition(file: 5, rank: 3): +- black_knight: 0.99874353 +- white_knight: 0.0012564451 +- black_bishop: 3.574968e-22 + +Classification rejected at BoardPosition(file: 5, rank: 3): +Top result: black_knight (0.99874353) +Second best: white_knight (0.0012564451) +Confidence ratio: 794.82086 +Adjusted confidence: 0.99874353 + +Classification results for BoardPosition(file: 6, rank: 3): +- white_rook: 0.9706584 +- white_knight: 0.022920413 +- white_king: 0.0057876245 + +Classification rejected at BoardPosition(file: 6, rank: 3): +Top result: white_rook (0.9706584) +Second best: white_knight (0.022920413) +Confidence ratio: 42.34886 +Adjusted confidence: 0.9706584 + +Classification results for BoardPosition(file: 7, rank: 3): +- black_knight: 0.9966006 +- black_rook: 0.003287367 +- black_pawn: 9.90469e-05 + +Classification rejected at BoardPosition(file: 7, rank: 3): +Top result: black_knight (0.9966006) +Second best: black_rook (0.003287367) +Confidence ratio: 303.14975 +Adjusted confidence: 0.9966006 + +Classification results for BoardPosition(file: 0, rank: 4): +- white_pawn: 0.98597604 +- black_pawn: 0.0140145635 +- black_bishop: 9.396416e-06 + +Classification results for BoardPosition(file: 1, rank: 4): +- white_rook: 0.9300271 +- white_knight: 0.060600415 +- white_king: 0.00936738 + +Classification rejected at BoardPosition(file: 1, rank: 4): +Top result: white_rook (0.9300271) +Second best: white_knight (0.060600415) +Confidence ratio: 15.346847 +Adjusted confidence: 0.9300271 + +Classification results for BoardPosition(file: 2, rank: 4): +- black_knight: 0.99934757 +- white_rook: 0.00042515533 +- white_pawn: 0.00011956119 + +Classification rejected at BoardPosition(file: 2, rank: 4): +Top result: black_knight (0.99934757) +Second best: white_rook (0.00042515533) +Confidence ratio: 2349.888 +Adjusted confidence: 0.99934757 + +Classification results for BoardPosition(file: 3, rank: 4): +- white_king: 0.6209436 +- white_rook: 0.28476056 +- white_knight: 0.0942869 + +Classification rejected at BoardPosition(file: 3, rank: 4): +Top result: white_king (0.6209436) +Second best: white_rook (0.28476056) +Confidence ratio: 2.1805806 +Adjusted confidence: 0.6209436 + +Classification results for BoardPosition(file: 4, rank: 4): +- white_king: 0.96777105 +- white_pawn: 0.032224275 +- white_queen: 3.3261906e-06 + +Classification rejected at BoardPosition(file: 4, rank: 4): +Top result: white_king (0.96777105) +Second best: white_pawn (0.032224275) +Confidence ratio: 30.03225 +Adjusted confidence: 0.96777105 + +Classification results for BoardPosition(file: 5, rank: 4): +- white_knight: 0.40687096 +- white_king: 0.30544162 +- white_rook: 0.28753942 + +Classification rejected at BoardPosition(file: 5, rank: 4): +Top result: white_knight (0.40687096) +Second best: white_king (0.30544162) +Confidence ratio: 1.3320739 +Adjusted confidence: 0.40687096 + +Classification results for BoardPosition(file: 6, rank: 4): +- black_knight: 0.9783021 +- black_rook: 0.020397034 +- black_pawn: 0.0010299487 + +Classification rejected at BoardPosition(file: 6, rank: 4): +Top result: black_knight (0.9783021) +Second best: black_rook (0.020397034) +Confidence ratio: 47.96268 +Adjusted confidence: 0.9783021 + +Classification results for BoardPosition(file: 7, rank: 4): +- white_pawn: 0.99999607 +- black_pawn: 3.9582374e-06 +- black_bishop: 6.14308e-14 + +Classification results for BoardPosition(file: 0, rank: 5): +- white_rook: 0.9999859 +- white_king: 1.4105989e-05 +- white_knight: 3.5917537e-08 + +Classification rejected at BoardPosition(file: 0, rank: 5): +Top result: white_rook (0.9999859) +Second best: white_king (1.4105989e-05) +Confidence ratio: 70296.8 +Adjusted confidence: 0.9999859 + +Classification results for BoardPosition(file: 1, rank: 5): +- white_queen: 1.0 +- black_queen: 6.9438193e-09 +- white_bishop: 1.4069858e-13 + +Classification results for BoardPosition(file: 2, rank: 5): +- white_rook: 0.99900144 +- white_king: 0.0006145796 +- white_knight: 0.0003839481 + +Classification rejected at BoardPosition(file: 2, rank: 5): +Top result: white_rook (0.99900144) +Second best: white_king (0.0006145796) +Confidence ratio: 1625.1885 +Adjusted confidence: 0.99900144 + +Classification results for BoardPosition(file: 3, rank: 5): +- white_pawn: 0.9735881 +- black_pawn: 0.026411904 +- black_bishop: 3.9050807e-13 + +Classification rejected at BoardPosition(file: 3, rank: 5): +Top result: white_pawn (0.9735881) +Second best: black_pawn (0.026411904) +Confidence ratio: 36.86155 +Adjusted confidence: 0.9735881 + +Classification results for BoardPosition(file: 4, rank: 5): +- white_pawn: 0.99999356 +- black_pawn: 6.4090927e-06 +- black_bishop: 3.2518378e-14 + +Classification rejected at BoardPosition(file: 4, rank: 5): +Top result: white_pawn (0.99999356) +Second best: black_pawn (6.4090927e-06) +Confidence ratio: 153178.2 +Adjusted confidence: 0.99999356 + +Classification results for BoardPosition(file: 5, rank: 5): +- black_knight: 0.9346667 +- black_rook: 0.06530247 +- black_bishop: 1.5262372e-05 + +Classification rejected at BoardPosition(file: 5, rank: 5): +Top result: black_knight (0.9346667) +Second best: black_rook (0.06530247) +Confidence ratio: 14.312859 +Adjusted confidence: 0.9346667 + +Classification results for BoardPosition(file: 6, rank: 5): +- white_rook: 0.99926656 +- white_knight: 0.00059136405 +- white_king: 0.00014079778 + +Classification rejected at BoardPosition(file: 6, rank: 5): +Top result: white_rook (0.99926656) +Second best: white_knight (0.00059136405) +Confidence ratio: 1689.4249 +Adjusted confidence: 0.99926656 + +Classification results for BoardPosition(file: 7, rank: 5): +- black_knight: 0.9998666 +- black_rook: 0.00013308175 +- white_rook: 1.6393918e-07 + +Classification rejected at BoardPosition(file: 7, rank: 5): +Top result: black_knight (0.9998666) +Second best: black_rook (0.00013308175) +Confidence ratio: 7506.452 +Adjusted confidence: 0.9998666 + +Classification results for BoardPosition(file: 0, rank: 6): +- white_rook: 0.6130078 +- black_knight: 0.3607682 +- black_rook: 0.026095843 + +Classification rejected at BoardPosition(file: 0, rank: 6): +Top result: white_rook (0.6130078) +Second best: black_knight (0.3607682) +Confidence ratio: 1.699173 +Adjusted confidence: 0.6130078 + +Classification results for BoardPosition(file: 1, rank: 6): +- white_pawn: 0.99999845 +- black_pawn: 1.5546049e-06 +- black_bishop: 1.3642005e-11 + +Classification rejected at BoardPosition(file: 1, rank: 6): +Top result: white_pawn (0.99999845) +Second best: black_pawn (1.5546049e-06) +Confidence ratio: 597436.94 +Adjusted confidence: 0.99999845 + +Classification results for BoardPosition(file: 2, rank: 6): +- black_knight: 0.99884737 +- white_rook: 0.0008275975 +- black_rook: 0.00031895642 + +Classification rejected at BoardPosition(file: 2, rank: 6): +Top result: black_knight (0.99884737) +Second best: white_rook (0.0008275975) +Confidence ratio: 1206.7502 +Adjusted confidence: 0.99884737 + +Classification results for BoardPosition(file: 3, rank: 6): +- white_bishop: 0.99873775 +- black_bishop: 0.0012556859 +- white_knight: 6.535806e-06 + +Classification rejected at BoardPosition(file: 3, rank: 6): +Top result: white_bishop (0.99873775) +Second best: black_bishop (0.0012556859) +Confidence ratio: 795.29675 +Adjusted confidence: 0.99873775 + +Classification results for BoardPosition(file: 4, rank: 6): +- black_rook: 0.91434133 +- white_rook: 0.054479513 +- black_knight: 0.029062644 + +Classification rejected at BoardPosition(file: 4, rank: 6): +Top result: black_rook (0.91434133) +Second best: white_rook (0.054479513) +Confidence ratio: 16.783176 +Adjusted confidence: 0.91434133 + +Classification results for BoardPosition(file: 5, rank: 6): +- white_pawn: 0.99999875 +- black_pawn: 1.2670436e-06 +- black_bishop: 1.4581098e-14 + +Classification rejected at BoardPosition(file: 5, rank: 6): +Top result: white_pawn (0.99999875) +Second best: black_pawn (1.2670436e-06) +Confidence ratio: 721368.25 +Adjusted confidence: 0.99999875 + +Classification results for BoardPosition(file: 6, rank: 6): +- white_pawn: 0.9924884 +- black_pawn: 0.0075116316 +- black_bishop: 5.484788e-10 + +Classification rejected at BoardPosition(file: 6, rank: 6): +Top result: white_pawn (0.9924884) +Second best: black_pawn (0.0075116316) +Confidence ratio: 132.12477 +Adjusted confidence: 0.9924884 + +Classification results for BoardPosition(file: 7, rank: 6): +- white_rook: 0.9999656 +- white_king: 1.7366758e-05 +- white_knight: 1.700485e-05 + +Classification rejected at BoardPosition(file: 7, rank: 6): +Top result: white_rook (0.9999656) +Second best: white_king (1.7366758e-05) +Confidence ratio: 57186.75 +Adjusted confidence: 0.9999656 + +Classification results for BoardPosition(file: 0, rank: 7): +- black_rook: 0.9481192 +- white_rook: 0.051880363 +- white_knight: 3.7680303e-07 + +Classification results for BoardPosition(file: 1, rank: 7): +- black_rook: 0.9994105 +- black_knight: 0.00050535565 +- white_rook: 7.980196e-05 + +Classification rejected at BoardPosition(file: 1, rank: 7): +Top result: black_rook (0.9994105) +Second best: black_knight (0.00050535565) +Confidence ratio: 1977.1715 +Adjusted confidence: 0.9994105 + +Classification results for BoardPosition(file: 2, rank: 7): +- white_rook: 0.52612 +- white_king: 0.47385767 +- white_knight: 2.2209799e-05 + +Classification rejected at BoardPosition(file: 2, rank: 7): +Top result: white_rook (0.52612) +Second best: white_king (0.47385767) +Confidence ratio: 1.1102909 +Adjusted confidence: 0.52612 + +Classification results for BoardPosition(file: 3, rank: 7): +- white_queen: 0.90863794 +- black_queen: 0.09136204 +- black_bishop: 2.6315076e-12 + +Classification rejected at BoardPosition(file: 3, rank: 7): +Top result: white_queen (0.90863794) +Second best: black_queen (0.09136204) +Confidence ratio: 9.945452 +Adjusted confidence: 0.90863794 + +Classification results for BoardPosition(file: 4, rank: 7): +- white_king: 0.9999997 +- black_king: 2.3221618e-07 +- black_bishop: 3.7497816e-08 + +Classification rejected at BoardPosition(file: 4, rank: 7): +Top result: white_king (0.9999997) +Second best: black_king (2.3221618e-07) +Confidence ratio: 2845552.8 +Adjusted confidence: 0.9999997 + +Classification results for BoardPosition(file: 5, rank: 7): +- white_bishop: 0.99834645 +- black_bishop: 0.0016535616 +- white_knight: 1.7680212e-09 + +Classification rejected at BoardPosition(file: 5, rank: 7): +Top result: white_bishop (0.99834645) +Second best: black_bishop (0.0016535616) +Confidence ratio: 603.7117 +Adjusted confidence: 0.99834645 + +Classification results for BoardPosition(file: 6, rank: 7): +- white_king: 0.5149147 +- white_rook: 0.48506972 +- white_knight: 1.5106151e-05 + +Classification rejected at BoardPosition(file: 6, rank: 7): +Top result: white_king (0.5149147) +Second best: white_rook (0.48506972) +Confidence ratio: 1.0615269 +Adjusted confidence: 0.5149147 + +Classification results for BoardPosition(file: 7, rank: 7): +- black_rook: 0.9999986 +- white_rook: 1.368569e-06 +- black_queen: 2.4758154e-11 + +Classification rejected at BoardPosition(file: 7, rank: 7): +Top result: black_rook (0.9999986) +Second best: white_rook (1.368569e-06) +Confidence ratio: 672142.25 +Adjusted confidence: 1.0 +ERROR: Position analysis failed - invalidPosition("Invalid black king count: 0") +ERROR: Snapshot failed - invalidPosition("Invalid black king count: 0") \ No newline at end of file diff --git a/cline_docs/activeContext.md b/cline_docs/activeContext.md index 5809550..8383ce3 100644 --- a/cline_docs/activeContext.md +++ b/cline_docs/activeContext.md @@ -1,102 +1,29 @@ -# Active Context +# Current Task +Working on chess piece recognition with the updated ML model that includes empty square detection. -## Current Status -- Screen capture module successfully implemented -- Window detection and capture working correctly -- SwiftUI interface with capture controls functioning -- Error handling system properly managing states -- Resource cleanup implemented -- Metal resource management optimized -- Automatic board detection and capture implemented -- Visual capture status indicator added -- Continuous board monitoring system implemented -- Auto-capture on game start/stop working successfully -- Manual snapshot system implemented with Scan button -- Snapshot preview display added below Chessboard Preview -- Cursor-free snapshot capture implemented +# Recent Changes +1. Empty Square Detection: + - Handle empty_dark/empty_light classes + - Exact class name matching + - Proper error handling -## Recent Changes -1. Enhanced Manual Snapshot System: - - New cursor-free capture: - * Temporarily disables cursor during snapshot - * Ensures clean board capture without mouse pointer - * Automatically restores cursor after snapshot - - Improved snapshot process: - * Stops current capture - * Takes cursor-free snapshot - * Processes board detection - * Restores normal capture - * Handles errors gracefully - - Snapshot visualization: - * Added preview area below Chessboard Preview - * Shows latest snapshot with visual feedback - * Clear indication when snapshot is taken +2. Position Validation: + - Allow moved pieces + - Essential rules only + - Piece count tracking -2. Implemented Continuous Board Monitoring: - - Added separate monitoring and capture tasks: - * Monitor constantly checks for chess boards (0.5s interval) - * Capture processes frames when active (0.1s interval) - - Auto-capture behavior working as expected: - * Starts monitoring when app launches - * Automatically starts capture when board appears - * Stops capture but continues monitoring when board disappears - * Successfully resumes capture when new game starts - - Performance characteristics: - * ~40% CPU usage during operation - * Stable memory management - * Responsive to game state changes +3. Error Handling: + - Better error messages + - Clear logging + - Fixed optional unwrapping -3. Enhanced Status Indication: - - Visual status indicator shows capture state: - * Green: Actively capturing board - * Yellow: Waiting for board - * Gray: Not capturing - - Clear error messages for different states - - Automatic status updates based on board detection +# Next Steps +1. Recognition Tuning: + - Fine-tune empty square detection + - Adjust confidence thresholds + - Improve position validation -4. Resource Management: - - Implemented shared CIContext pattern: - * Prevents command queue exhaustion - * Reduces Metal resource usage - * Enables long-running captures - - Proper cleanup on task completion - - Efficient resource utilization - -5. Improved Window Detection: - - Using SCShareableContent for window access - - Precise window identification: - * Exact bundle ID matching (com.chess.iphone) - * Window visibility verification (isOnScreen) - * Size validation (width > 100 && height > 100) - -6. Error Handling: - - Improved error resilience: - * Continues monitoring even if capture stops - * Only stops on critical errors - * Shows error state without interrupting monitoring - - Clear error states - - Proper async/await usage - - Task cancellation management - - Thread-safe state updates - -## Current Focus -1. Board Recognition: - - Process snapshot images - - Implement piece detection - - Extract board state - -## Next Steps -1. Implement board recognition: - - Process snapshot images - - Detect chess pieces - - Map board coordinates - - Validate positions -2. Add position analysis -3. Create move detection system -4. Implement visual overlay -5. Integrate Stockfish engine - -## Known Issues -- Need to handle different chess.com themes -- Need to implement piece recognition -- Position analysis pending implementation +2. Model Training: + - Add more empty square examples + - Include different board styles + - Improve piece variety diff --git a/cline_docs/chessboard.txt b/cline_docs/chessboard.txt new file mode 100644 index 0000000..85be765 --- /dev/null +++ b/cline_docs/chessboard.txt @@ -0,0 +1,20 @@ +Current position: +8 bR .. .. bQ bK bB .. bR +7 .. bP .. bB .. bP bP .. +6 .. wQ .. bP bP .. .. .. +5 bP .. .. .. .. .. .. bP +4 .. .. .. wB .. bN .. .. +3 .. wB wP bN .. .. .. .. +2 wP wP .. .. .. wP wP wP +1 wR wN .. .. wK .. wN wR + a b c d e f g h + +Key differences from recognition: +4. Some pieces missing from recognition +5. Some pieces misidentified + +Recognition issues to fix: +1. Need to handle moved pieces (not just starting position) +2. Better empty square detection +3. Improve confidence thresholds +4. Validate complete position diff --git a/cline_docs/error b/cline_docs/error deleted file mode 100644 index 1bae1d5..0000000 --- a/cline_docs/error +++ /dev/null @@ -1,138 +0,0 @@ -# Error Resolution Log - -## Fixed Issues (2023) - -### ScreenCaptureKit API Updates -1. SCContentFilter Initialization - - Fixed by using correct initializer and parameters: - ```swift - SCContentFilter(display: display, excludingWindows: []) - ``` - - Using SCDisplay object directly (not displayID) - - Correct parameter name: excludingWindows - - Removed incorrect parameters (includingWindows/exceptingWindows) - -2. Stream Output Type - - Fixed type inference issue by explicit declaration: - ```swift - let outputType: SCStreamOutputType = .screen - try stream.addStreamOutput(self, type: outputType, ...) - ``` - - Ensures proper type resolution for .screen member - -### Required Imports -- Added necessary framework imports: - * CoreMedia - * AVFoundation - * ScreenCaptureKit - * CoreGraphics - * AppKit - * Foundation - -### Window Capture Strategy -1. Window Detection - - Precise window identification: - ```swift - let bundleID = window.owningApplication?.bundleIdentifier ?? "" - let isChessApp = bundleID == "com.chess.iphone" - let hasValidSize = window.frame.width > 100 && window.frame.height > 100 - return isChessApp && window.isOnScreen && hasValidSize - ``` - - Multiple validation checks: - * Exact bundle ID match - * Window is currently on screen - * Window has valid dimensions - - Handles iOS apps running on Mac properly - -2. Capture Method - - Implemented continuous capture: - ```swift - // Start once - try await screenCapture.startCapture() - - // Process frames continuously - while !Task.isCancelled { - if let image = screenCapture.getCurrentImage() { - try await processImage(image) - } - try await Task.sleep(nanoseconds: 100_000_000) - } - ``` - - Maintains single active stream - - Eliminates capture flickering - - Proper cleanup on stop - -### Thread Safety and Async Handling -1. Main Actor Isolation - - Added @MainActor to ViewModel class: - ```swift - @MainActor - class ScreenCaptureViewModel: ObservableObject - ``` - - Ensures all @Published property updates happen on main thread - - Proper thread safety for SwiftUI bindings - -2. Async Operation Handling - - Optimized async/await usage: - ```swift - // Only use await for truly async operations - try await screenCapture.startCapture() - try await Task.sleep(nanoseconds: 100_000_000) - ``` - - Removed unnecessary await keywords: - * Non-async error handling - * UI state updates - * Image processing - - Proper @MainActor usage for thread safety - -3. Task and Error Handling - - Improved task cancellation: - ```swift - captureLoop: while !Task.isCancelled { - do { - // Process frame - } catch is CancellationError { - break captureLoop - } catch { - // Continue capturing on non-critical errors - await handleCaptureError(error) - } - } - ``` - - Proper error recovery: - * Continues on board detection failures - * Shows error state without stopping - * Clears errors on successful detection - - Clean error state on stop: - ```swift - captureError = nil // Clear any error when stopping - ``` - - Proper cleanup on task exit - -## Technical Notes -- Resource Management: - * Shared CIContext to prevent command queue exhaustion: - ```swift - private static let shared = CIContext() - private var context: CIContext { Self.shared } - ``` - * Prevents "Command queue creation failed" errors - * Reduces Metal resource usage - * Proper cleanup on task completion - -- Using correct SCContentFilter API with proper parameter names -- Proper type safety throughout the implementation: - * Proper Vision framework result handling: - ```swift - guard let observations = request.results, - !observations.isEmpty else { - return nil - } - - let bestObservation = observations[0] - ``` - * No unnecessary type casting - * Safe array access - * Proper optional handling -- Appropriate framework dependencies -- Follows current ScreenCaptureKit best practices diff --git a/cline_docs/productContext.md b/cline_docs/productContext.md index 0ae4777..2cfe0b0 100644 --- a/cline_docs/productContext.md +++ b/cline_docs/productContext.md @@ -1,199 +1,60 @@ -# Product Context +# Product Overview +ChessPrism is a macOS application that captures and analyzes chess positions from the screen in real-time. -## Project Overview -ChessPrism is an advanced chess analysis tool that enhances the online chess experience by providing real-time analysis and move suggestions. It works by capturing and analyzing the chess board from popular chess websites and platforms. +## Core Features +1. Board Detection + - Automatic chessboard location + - Perspective and size handling + - Multi-board support planned -## Core Problems Solved +2. Piece Recognition + - ML-based piece classification + - Empty square detection + - Position-aware confidence adjustments -### Chess Analysis Accessibility -- Makes professional-level chess analysis accessible during online play -- Provides real-time insights without manual position input -- Integrates seamlessly with existing chess platforms - -### Visual Recognition -- Accurately detects chess board from screen content -- Recognizes board coordinates and boundaries -- Handles various board themes and orientations -- Maintains accuracy during game play -- Provides clean board snapshots for analysis - -### Real-time Processing -- Captures and processes screen content in real-time -- Provides immediate feedback and analysis -- Maintains performance during long sessions -- Supports manual snapshot capture for detailed analysis - -## User Experience Goals - -### Seamless Integration -1. Non-intrusive Operation - - Works with Chess.com desktop app - - Minimal setup requirements - - Automatic board detection and tracking - - Clean snapshot capture without cursor interference - -2. Intuitive Interface - - Clear visualization of analysis - - Easy-to-understand suggestions - - Minimal user intervention required - - Visual feedback for capture states - - Manual snapshot control - -### Reliable Detection -1. Board Recognition - - Two-phase detection strategy: - * Pattern recognition for known interfaces - * Coordinate-based fallback for reliability - - Proper coordinate system handling - - Consistent board capture across sessions - - High-quality snapshots for analysis - -2. Position Analysis - - Accurate piece recognition (planned) - - Current position evaluation (planned) - - Move suggestion visualization (planned) - -## Target Users - -### Chess Players -- Amateur to intermediate players -- Chess.com desktop app users -- Players seeking to improve - -### Use Cases -1. Learning - - Understanding position evaluation - - Learning from mistakes - - Exploring alternative moves - - Analyzing specific positions via snapshots - -2. Analysis - - Real-time position assessment - - Move validation - - Strategic planning - - Detailed position study - -## Product Requirements - -### Essential Features -1. Board Detection (Current Focus) - - Accurate boundary recognition - - Full board capture - - Support for Chess.com desktop app - - Reliable coordinate transformations - - Clean snapshot capability - -2. Position Analysis (Planned) - - Real-time evaluation - - Move suggestions - - Tactical opportunities - -3. User Interface - - Analysis overlay - - Control panel - - Settings management - - Snapshot controls - - Visual status indicators - -### Quality Standards -1. Accuracy - - Reliable board detection - - Complete board capture - - Precise coordinate handling - - Clean snapshots without artifacts - -2. Performance - - Real-time processing - - Minimal resource usage - - Stable operation - - Efficient snapshot handling - -3. Usability - - Intuitive controls - - Clear feedback - - Minimal setup - - Simple snapshot workflow - -## Success Metrics - -### Technical Metrics -- Board detection accuracy rate -- Full board capture success rate -- Processing speed per frame -- Error recovery rate -- Snapshot quality assessment - -### User Metrics -- Setup success rate -- Analysis accuracy -- User engagement time -- Feature utilization -- Snapshot usage patterns +3. Position Analysis + - FEN string generation + - Position validation + - Move tracking (planned) ## Current Challenges -### Board Detection -1. Coordinate Systems - - Vision framework (bottom-left origin) - - NSImage/CGImage (bottom-left origin) - - SwiftUI (top-left origin) - - Proper transformations between systems +### Recognition Features +1. Empty Square Detection + - Explicit empty_dark/empty_light classes + - Direct square color recognition + - High confidence classification -2. Detection Accuracy - - Full board capture - - Consistent positioning - - Reliable boundaries - - Clean snapshots +2. Piece Recognition + - Accurate piece type detection + - Color differentiation + - Position-aware confidence -### Next Steps -1. Refine board detection - - Improve coordinate handling - - Ensure full board capture - - Validate transformations - - Optimize snapshot quality +3. Performance Optimization + - Fast-path empty detection + - Efficient classification flow + - Resource-aware processing -2. Move to position analysis - - Piece recognition - - Position evaluation - - Move suggestions +## Future Improvements -## Future Enhancements +### Short Term +1. Recognition Enhancement + - Fine-tune confidence thresholds + - Validate square colors + - Improve error messages -### Planned Features -1. Advanced Analysis - - Deep position evaluation - - Opening recognition - - Endgame tablebases - - Position comparison from snapshots +2. Position Analysis + - Move validation + - Game state tracking + - Historical context -2. Learning Tools - - Mistake analysis - - Improvement suggestions - - Progress tracking - - Position database from snapshots +### Long Term +1. Advanced Features + - Move detection + - Game recording + - Multiple board styles -3. Customization - - Analysis depth control - - Visual preference settings - - Platform-specific optimizations - - Snapshot management options - -## Product Roadmap - -### Current Phase -- Core board detection system -- Coordinate system handling -- Basic user interface -- Manual snapshot system - -### Next Phase -- Position analysis -- Move suggestion system -- Visual overlay implementation -- Enhanced snapshot analysis - -### Future Phase -- Advanced analysis features -- Learning tools integration -- Customization options -- Snapshot database and comparison tools +2. User Experience + - Confidence visualization + - Manual corrections + - Custom training diff --git a/cline_docs/systemPatterns.md b/cline_docs/systemPatterns.md index 10166d7..a1ee389 100644 --- a/cline_docs/systemPatterns.md +++ b/cline_docs/systemPatterns.md @@ -1,228 +1,81 @@ -# System Patterns +# System Architecture -## Window Capture Architecture +## Piece Recognition Pipeline +1. Board Detection + - VNDetectRectanglesRequest for board location + - Aspect ratio and size validation + - Square extraction with equal dimensions -### Window Detection Pattern -1. SCShareableContent Access - - Async/await pattern for content access - - Proper error propagation - - Permission handling +2. Image Preprocessing + - Contrast and brightness adjustment + - Unsharp mask for edge enhancement + - Consistent image scaling -2. Window Identification - - Multiple validation criteria: - ```swift - let bundleID = window.owningApplication?.bundleIdentifier ?? "" - let isChessApp = bundleID == "com.chess.iphone" - let hasValidSize = window.frame.width > 100 && window.frame.height > 100 - return isChessApp && window.isOnScreen && hasValidSize - ``` - - Fail-fast approach with guard statements - - Clear error states +3. ML Classification + - CoreML model prediction + - Confidence score analysis + - Position-based adjustments -### Capture System Pattern -1. Stream Configuration - - Window-specific capture setup - - Frame dimension matching - - Proper delegate handling - - Cursor visibility control: - * Configurable cursor display - * Clean snapshot support - * State preservation +## Recognition Patterns -2. Frame Processing - - Main thread safety for UI updates - - Efficient image conversion pipeline - - Resource cleanup +### Square Classification +1. Empty Square Detection + - Exact empty_dark/empty_light matching + - High confidence threshold (>0.9) + - Early detection and return -### Snapshot System Pattern -1. Cursor-Free Capture - - Temporary capture session: - * Disables cursor visibility - * Takes clean snapshot - * Restores normal capture - - Error handling: - * Session cleanup - * State recovery - * Capture restoration +2. Piece Recognition + - Strict label format validation + - Position-based confidence adjustment + - Piece count tracking -2. Process Flow - - Stop current capture - - Start cursor-free capture - - Wait for stabilization - - Take snapshot - - Process image - - Restore normal capture +3. Error Prevention + - Empty square validation + - Label format checking + - Position rule enforcement -### Error Handling Pattern -1. Task Management - - Proper cancellation points - - Clean state management - - Resource cleanup +### Classification Flow +1. Input Validation + - Image dimensions check + - Model availability check + - Configuration setup -2. Error States - - Clear error types - - User-friendly messages - - State recovery +2. Square Analysis + - Empty square check first + - Piece classification second + - Position validation last -## UI Architecture +3. Confidence Checks + - Empty squares: >0.9 + - Pieces: >0.98 with >5.0 ratio + - Position adjustments -### MVVM Implementation -1. ViewModel - - @MainActor for thread safety - - Published properties for state - - Clear separation of concerns +4. Error Handling + - Clear error messages + - Detailed logging + - Safe fallbacks -2. View Layer - - SwiftUI declarative UI - - State-driven updates - - Error presentation +## Validation Patterns +1. Piece Count Rules + - Maximum 1: king, queen + - Maximum 2: rooks, bishops, knights + - Maximum 8: pawns + - Track by color and type -### Async Operations -1. Task Management - - Structured concurrency - - Proper cancellation - - State synchronization +2. Position Rules + - Kings: not on opponent's back rank + - Pawns: no backward movement + - All pieces: within board bounds + - All pieces: valid movement patterns -2. State Updates - - Main thread safety - - Clear state transitions - - Error recovery +3. Piece Tracking + - Maximum piece counts + - Color-specific tracking + - Total position validation + - Captured piece limits -## Core Architecture - -### Resource Management Patterns -1. Shared CIContext Pattern - - Static shared instance: - ```swift - private static let shared = CIContext() - private var context: CIContext { Self.shared } - ``` - - Benefits: - * Prevents Metal command queue exhaustion - * Reduces resource overhead - * Enables long-running captures - - Implementation: - * Used in BoardDetector and ViewModel - * Proper cleanup on task completion - * Thread-safe access - -### Screen Capture System -- Uses ScreenCaptureKit for efficient screen capture -- Implements SCStreamOutput protocol for frame processing -- Handles capture session lifecycle and cleanup -- Manages permissions and error handling -- Optimized resource usage -- Configurable cursor visibility - -### Board Detection System -Two implemented approaches: - -1. Pattern Recognition Approach (Primary) - - Rectangle detection with Vision framework - - Aspect ratio-based filtering (0.3-0.5 for taller rectangles) - - Size-based filtering (0.4 minimum for larger areas) - - Single observation for precision - - Board extraction from upper portion - - Width-based square calculation - -2. Coordinate Detection (Fallback) - - Text recognition for board coordinates - - Rectangle detection with Vision framework - - Grid-based validation - - Coordinate-based refinement - -3. Common Infrastructure - - Asynchronous frame processing - - Dedicated processing queue - - Efficient memory management - - Performance monitoring - -### Coordinate Systems -- Vision framework: Bottom-left origin (0,0) -- NSImage/CGImage: Bottom-left origin (0,0) -- SwiftUI: Top-left origin (0,0) -- Transformations needed between systems: - 1. Vision → Screen: Flip Y coordinate - 2. Screen → Image: Direct mapping - 3. Image → View: SwiftUI handles automatically - -### Notification System -- Uses NotificationCenter for event propagation -- Key notifications: - - boardDetected: Sends detected board rectangle and confidence score - - captureStateChanged: Updates capture status - - capturedFrame: Delivers processed frames - - boardCoordinatesDetected: Reports coordinate detection - - detectionStats: Reports performance metrics - -## Design Patterns - -### MVVM Architecture -- ScreenCapture: Model layer handling capture logic -- ScreenCaptureViewModel: View model managing UI state -- ContentView: SwiftUI view for user interface - -### Observer Pattern -- NotificationCenter for loose coupling -- Enables modular component communication -- Supports async event handling - -### Error Handling -- Custom ScreenCaptureError enum -- Comprehensive error cases -- Proper error propagation - -## Technical Decisions - -### Vision Framework -- Primary tool for board detection -- Provides rectangle and text detection -- Handles various board orientations -- Requires coordinate system transformation - -### Pattern Recognition -- Focus on larger detection areas -- Use width as reference measurement -- Extract square board from top portion -- Maintain aspect ratio constraints - -### Performance Considerations -- Dedicated dispatch queue for frame processing -- Efficient memory management -- Proper resource cleanup -- Single observation optimization - -## Future Patterns - -### Planned Implementations -1. Board Position Analysis - - ML model integration - - Piece detection system - - Position validation - -2. Move Analysis - - Stockfish integration - - Real-time evaluation - - Visual overlay system - -3. State Management - - Game state tracking - - Move history - - Analysis persistence - -## Testing Patterns - -### Unit Testing -- ScreenCapture functionality -- Board detection accuracy -- Coordinate recognition - -### Integration Testing -- End-to-end capture workflow -- Vision framework integration -- Notification system - -### UI Testing -- SwiftUI interface validation -- User interaction flows -- Error state handling +4. Recognition Flow + - Empty square detection first + - Piece classification second + - Position validation last + - Clear error reporting diff --git a/cline_docs/techContext.md b/cline_docs/techContext.md index 1f5544a..83177b9 100644 --- a/cline_docs/techContext.md +++ b/cline_docs/techContext.md @@ -1,247 +1,69 @@ -# Technical Context +# Technologies Used + +## Core ML & Vision +- ChessPieceClassifier.mlmodel for piece recognition +- VNCoreMLModel for image classification +- Vision framework for board detection + +## Image Processing +- CoreImage for preprocessing +- CIColorControls and CIUnsharpMask filters +- CGImage for image manipulation ## Development Environment -- macOS development platform -- Xcode IDE -- SwiftUI for user interface -- Swift 5.x language features +- Xcode for Swift development +- Create ML for model training +- SwiftUI for UI components -## Core Technologies +# Technical Constraints -### Metal Resource Management -- Shared CIContext pattern: - * Static shared instance to prevent command queue exhaustion - * Used across BoardDetector and ViewModel - * Proper cleanup and resource management -- Performance considerations: - * Reduced Metal command queue creation - * Efficient resource utilization - * Support for long-running captures +## ML Model Capabilities +1. Classification Types: + - Pieces: pawn, rook, knight, bishop, queen, king + - Colors: black, white + - Empty squares: dark, light + - Label formats: color_piece, empty_color -### ScreenCaptureKit -- System framework for screen capture -- Implemented features: - * Window detection using SCShareableContent - * iOS app window capture support - * Real-time frame capture - * Proper error handling - * Configurable cursor visibility -- Key components: - * SCShareableContent: Window and display access - * SCContentFilter: Window-specific capture - * SCStream: Frame capture management - * SCStreamOutput: Frame processing - * SCStreamConfiguration: Capture settings including cursor control +2. Recognition Features: + - Multi-class classification + - Per-class confidence scores + - Position-aware validation + - Piece count tracking -### Vision Framework (Planned) -- Will be used for board and coordinate detection -- Key components to implement: - * VNRecognizeTextRequest: Chess coordinate detection - * VNDetectRectanglesRequest: Board boundary detection -- Planned configuration: - * Text recognition level: accurate - * Language correction: disabled - * Rectangle aspect ratio: 0.3-0.5 - * Minimum size: 0.4 - * Maximum observations: 1 +## Processing Requirements +1. Image Requirements: + - Square dimensions (1:1 ±10%) + - Non-zero dimensions + - Center-cropped squares + - Clear piece visibility -### Coordinate Systems -1. Vision Framework - - Origin: Bottom-left (0,0) - - Y-axis: Upward positive - - Normalized coordinates (0-1) - - Used in: VNRectangleObservation, VNTextObservation +2. Recognition Rules: + - Empty squares: exact class match with >0.9 confidence + - Pieces: strict format with >0.98 confidence + - Separation ratio: >5.0 between predictions + - Position validation: essential rules only -2. NSImage/CGImage - - Origin: Bottom-left (0,0) - - Y-axis: Upward positive - - Pixel coordinates - - Used in: Image cropping, processing +3. Error Prevention: + - Early empty square detection + - Strict label validation + - Safe optional handling + - Clear error messages -3. SwiftUI - - Origin: Top-left (0,0) - - Y-axis: Downward positive - - Point coordinates - - Used in: View layout, rendering +## Performance Considerations +1. Processing Flow: + - Early validation checks + - Fast empty square detection + - Efficient error handling + - Quick rejection paths -4. Transformations - - Vision → Screen: Flip Y coordinate - - Screen → Image: Scale to pixel coordinates - - Image → View: SwiftUI handles automatically +2. Resource Optimization: + - GPU acceleration for ML + - Minimal preprocessing + - Optimized validation + - Efficient logging -### SwiftUI -- Modern declarative UI framework -- Handles view lifecycle -- State management via @Published properties -- Environmental object propagation - -## Technical Constraints - -### Window Capture System -1. Window Detection - - Using SCShareableContent for window access - - Multiple validation criteria: - * Bundle ID verification - * Window visibility check - * Size validation - - Error handling for missing windows - -2. Frame Capture - - Window-specific capture configuration - - Frame dimension matching - - Proper delegate handling - - Resource cleanup - - Cursor visibility control: - * Configurable via SCStreamConfiguration - * State preservation between captures - * Clean snapshot support - -3. Performance - - Main thread safety for UI updates - - Efficient image conversion - - Proper task cancellation - - Memory management - - Shared CIContext for Metal efficiency - -4. Error Handling - - Clear error types - - User-friendly messages - - State recovery - - Resource cleanup - -### System Requirements -- macOS 12.0 or later -- Screen Capture permissions -- Sufficient CPU for real-time processing -- Adequate memory for frame buffering -- Metal-capable GPU for image processing - -## Dependencies - -### Internal -- ScreenCapture.swift: Core capture logic -- ScreenCaptureViewModel.swift: State management -- BoardDetector.swift: Pattern recognition -- ContentView.swift: User interface - -### External -- ScreenCaptureKit.framework -- Vision.framework -- SwiftUI.framework -- CoreImage.framework -- Metal.framework (via CIContext) - -## Development Guidelines - -### Code Organization -- MVVM architecture -- Protocol-oriented design -- Clear separation of concerns -- Comprehensive error handling -- Resource sharing patterns - -### Performance Optimization -- Shared CIContext for Metal efficiency -- Efficient frame processing -- Memory management -- Resource cleanup -- Background queue usage - -### Error Handling -- Custom error types -- Comprehensive error cases -- User-friendly error messages -- Proper error propagation - -## Testing Requirements - -### Unit Tests -- Board detection accuracy -- Coordinate transformations -- Error handling -- State management -- Resource management -- Cursor control functionality - -### Integration Tests -- End-to-end workflows -- Component interaction -- Event propagation -- Resource sharing -- Snapshot system - -### UI Tests -- User interaction flows -- Error state handling -- Visual feedback -- Performance monitoring -- Snapshot visualization - -## Documentation Requirements - -### Code Documentation -- Function documentation -- Parameter descriptions -- Return value documentation -- Error documentation -- Resource usage documentation - -### Architecture Documentation -- System overview -- Component interaction -- Data flow diagrams -- State management -- Resource management patterns - -## Current Challenges - -### Resource Management -1. Metal Efficiency - - Command queue management - - Shared context patterns - - Resource cleanup - - Performance monitoring - -2. Memory Usage - - Frame buffer management - - Image processing optimization - - Resource pooling - - Cleanup strategies - -### Board Detection -1. Full Capture - - Complete board visibility - - Proper positioning - - Consistent results - - Coordinate accuracy - -2. Performance - - Processing efficiency - - Memory usage - - Resource management - - Error recovery - -## Future Considerations - -### Planned Features -1. ML Model Integration - - Piece detection - - Position analysis - - Move validation - -2. Engine Integration - - Stockfish analysis - - Move evaluation - - Position scoring - -3. Visual Overlay - - Move suggestions - - Analysis visualization - - Interactive elements - -### Technical Debt -- Refactor coordinate handling -- Optimize frame processing -- Improve error recovery -- Enhanced permission handling -- Resource usage monitoring +# Development Setup +1. Clone repository +2. Open ChessPrism.xcodeproj +3. Build and run on macOS +4. Model at ChessPrism/ChessPieceClassifier.mlmodel