import gradio as gr import numpy as np import cv2 from PIL import Image import os import chess import chess.engine # Robust imports ENGINE_CANDIDATES = ["stockfish", "/usr/bin/stockfish", "/usr/local/bin/stockfish"] try: from ultralytics import YOLO YOLO_AVAILABLE = True except Exception as e: YOLO_AVAILABLE = False YOLO_IMPORT_ERROR = str(e) YOLO_MODEL_ID = "yamero999/chess-piece-detection-yolo11n" YOLO_IMGSZ = 640 YOLO_CONF = 0.35 STOCKFISH_SKILL_LEVEL = 8 LABEL_TO_FEN = { "wpawn": "P", "wknight": "N", "wbishop": "B", "wrook": "R", "wqueen": "Q", "wking": "K", "bpawn": "p", "bknight": "n", "bbishop": "b", "brook": "r", "bqueen": "q", "bking": "k", "white_pawn": "P","white_knight":"N","white_bishop":"B","white_rook":"R","white_queen":"Q","white_king":"K", "black_pawn":"p","black_knight":"n","black_bishop":"b","black_rook":"r","black_queen":"q","black_king":"k", } def load_yolo(): if not YOLO_AVAILABLE: raise RuntimeError(f"Ultralytics not available: {YOLO_IMPORT_ERROR}") try: model = YOLO(YOLO_MODEL_ID) return model except Exception as e: raise RuntimeError(f"Failed to load YOLO model '{YOLO_MODEL_ID}': {e}") def detect_board_corners(img_bgr): gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) gray = cv2.GaussianBlur(gray, (5,5), 0) thr = cv2.adaptiveThreshold(gray,255,cv2.ADAPTIVE_THRESH_MEAN_C,cv2.THRESH_BINARY_INV, 31, 5) kernel = np.ones((3,3), np.uint8) thr = cv2.morphologyEx(thr, cv2.MORPH_CLOSE, kernel, iterations=2) contours, _ = cv2.findContours(thr, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None contours = sorted(contours, key=cv2.contourArea, reverse=True) for cnt in contours[:5]: peri = cv2.arcLength(cnt, True) approx = cv2.approxPolyDP(cnt, 0.02 * peri, True) if len(approx) == 4: pts = approx.reshape(4,2).astype(np.float32) s = pts.sum(axis=1) diff = np.diff(pts, axis=1).reshape(-1) tl = pts[np.argmin(s)] br = pts[np.argmax(s)] tr = pts[np.argmin(diff)] bl = pts[np.argmax(diff)] return np.array([tl,tr,br,bl], dtype=np.float32) return None def warp_to_topdown(img_bgr, corners): dst = np.array([[0,0],[800,0],[800,800],[0,800]], dtype=np.float32) M = cv2.getPerspectiveTransform(corners, dst) warped = cv2.warpPerspective(img_bgr, M, (800,800)) return warped def yolo_detect_pieces(model, img_bgr): results = model.predict(source=img_bgr[...,::-1], imgsz=YOLO_IMGSZ, conf=YOLO_CONF, verbose=False) dets = [] if not results: return dets res = results[0] names = res.names for b in res.boxes: cls_id = int(b.cls[0].item()) conf = float(b.conf[0].item()) x1,y1,x2,y2 = b.xyxy[0].tolist() dets.append({ "label": names.get(cls_id, str(cls_id)).lower(), "conf": conf, "bbox": (float(x1), float(y1), float(x2), float(y2)) }) return dets def piece_square_mapping(warped_bgr, detections): mapping = {} square_size = 100 for det in detections: fen_letter = LABEL_TO_FEN.get(det["label"]) if not fen_letter: # coarse fallback based on substrings lbl = det["label"] if "pawn" in lbl: fen_letter = 'P' if 'w' in lbl else 'p' elif "knight" in lbl: fen_letter = 'N' if 'w' in lbl else 'n' elif "bishop" in lbl: fen_letter = 'B' if 'w' in lbl else 'b' elif "rook" in lbl: fen_letter = 'R' if 'w' in lbl else 'r' elif "queen" in lbl: fen_letter = 'Q' if 'w' in lbl else 'q' elif "king" in lbl: fen_letter = 'K' if 'w' in lbl else 'k' if not fen_letter: continue x1,y1,x2,y2 = det["bbox"] cx = (x1 + x2) / 2.0 cy = (y1 + y2) / 2.0 col = int(np.clip(cx // square_size, 0, 7)) row = int(np.clip(cy // square_size, 0, 7)) file_char = chr(ord('a') + col) rank_char = str(8 - row) sq = f"{file_char}{rank_char}" old = mapping.get(sq) if old is None or det["conf"] > old["conf"]: mapping[sq] = {"fen": fen_letter, "conf": det["conf"]} return {k: v["fen"] for k,v in mapping.items()} def mapping_to_fen(square_map): rows = [] for r in range(8, 0, -1): row_str, empty = "", 0 for c in range(8): sq = f"{chr(ord('a')+c)}{r}" if sq in square_map: if empty: row_str += str(empty); empty = 0 row_str += square_map[sq] else: empty += 1 if empty: row_str += str(empty) rows.append(row_str) board_fen = "/".join(rows) return f"{board_fen} w - - 0 1" def find_stockfish(): for path in ENGINE_CANDIDATES: try: eng = chess.engine.SimpleEngine.popen_uci(path) return eng except Exception: continue raise RuntimeError("Stockfish engine not found. Ensure apt.txt installs it or set ENGINE_CANDIDATES.") def san_best_move_and_reason(fen): try: engine = find_stockfish() except Exception as e: return None, f"Engine error: {e}" try: try: engine.configure({"Skill Level": int(STOCKFISH_SKILL_LEVEL)}) except Exception: pass board = chess.Board(fen) info0 = engine.analyse(board, chess.engine.Limit(depth=10)) result = engine.play(board, chess.engine.Limit(depth=12)) move = result.move board.push(move) info1 = engine.analyse(board, chess.engine.Limit(depth=10)) def cp(info): s = info.get("score") if s is None: return None try: return s.white().score(mate_score=100000) except Exception: return None before, after = cp(info0), cp(info1) san = board.peek().san() board.pop() text = "This move improves your coordination and keeps the position stable." if before is not None and after is not None: delta = after - before if delta >= 80: text = "A strong move that clearly improves your position and creates threats." elif delta >= 30: text = "A good developing move that gains a small but steady advantage." elif delta >= 5: text = "A useful move that slightly improves your position." elif delta >= -5: text = "A safe, solid move that keeps the balance." else: text = "A practical choice to avoid complications." return san, text finally: try: engine.quit() except Exception: pass def try_opening_name(fen): try: import chess.openings board = chess.Board(fen) name = chess.openings.opening_name(board) return name or "No named opening (or midgame)." except Exception: return "No named opening (or midgame)." def draw_move(img_bgr, san, fen): try: board = chess.Board(fen) mv = board.parse_san(san) except Exception: return img_bgr def center(sq): file = chess.square_file(sq) rank = chess.square_rank(sq) col = file row_top = 7 - rank return (int((col+0.5)*100), int((row_top+0.5)*100)) a = center(mv.from_square); b = center(mv.to_square) out = img_bgr.copy() cv2.arrowedLine(out, a, b, (0,255,0), 4, tipLength=0.25) return out def process(image): if image is None: return None, "", "", "", "Please upload a board image." img_bgr = cv2.cvtColor(np.array(image.convert("RGB")), cv2.COLOR_RGB2BGR) corners = detect_board_corners(img_bgr) warped = cv2.resize(img_bgr, (800,800)) if corners is None else warp_to_topdown(img_bgr, corners) # Load YOLO try: model = load_yolo() except Exception as e: return Image.fromarray(cv2.cvtColor(warped, cv2.COLOR_BGR2RGB)), "", "", "", f"Detector load error: {e}" try: dets = yolo_detect_pieces(model, warped) sq_map = piece_square_mapping(warped, dets) fen = mapping_to_fen(sq_map) except Exception as e: return Image.fromarray(cv2.cvtColor(warped, cv2.COLOR_BGR2RGB)), "", "", "", f"Detection/FEN error: {e}" san, why = san_best_move_and_reason(fen) if san is None: out = warped move = "" err = why else: out = draw_move(warped, san, fen) move = san err = "" opening = try_opening_name(fen) out_img = Image.fromarray(cv2.cvtColor(out, cv2.COLOR_BGR2RGB)) return out_img, fen, move, opening, why if not err else err title_md = "# Chess Assist (v3): Image → FEN → Best Move" with gr.Blocks() as demo: gr.Markdown(title_md) with gr.Row(): with gr.Column(): img = gr.Image(type="pil", label="Upload a chessboard photo") go = gr.Button("Analyze") with gr.Column(): vis = gr.Image(type="pil", label="Move overlay") fen = gr.Textbox(label="FEN") mv = gr.Textbox(label="Best Move (SAN)") opening = gr.Textbox(label="Opening") why = gr.Textbox(label="Why this move? / Errors") go.click(process, inputs=[img], outputs=[vis, fen, mv, opening, why]) if __name__ == "__main__": demo.launch(server_name="0.0.0.0", server_port=7860, share=False)