Spaces:
Runtime error
Runtime error
| 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) | |