commit 04250bd4b5324edf7a4dfff1492f94469f2c924d Author: Egor Isaev Date: Wed Jun 17 16:45:21 2026 +0300 first commit diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md new file mode 100644 index 0000000..8240729 --- /dev/null +++ b/.claude/memory/MEMORY.md @@ -0,0 +1,8 @@ +# Memory Index + +- [Тест перед запуском](feedback_test_before_telling.md) — всегда тестировать код самостоятельно перед тем, как говорить пользователю запустить +- [Очистка tmp](feedback_cleanup_tmp.md) — удалять отладочные скриншоты из /home/isaevea/python/pp/tmp после завершения работы +- [Временная папка](project_tmp_dir.md) — все временные файлы сохранять в /home/isaevea/python/pp/tmp +- [Обзор проекта](project_overview.md) — покер-анализатор GGPoker через вебкамеру, структура файлов +- [Железо и камера](project_hardware.md) — Logitech StreamCam /dev/video2, координаты GGPoker в кадре, оптимальные настройки +- [dickreuter_poker](project_dickreuter.md) — внешняя зависимость, Monte Carlo, скрапер, статус интеграции diff --git a/.claude/memory/feedback_cleanup_tmp.md b/.claude/memory/feedback_cleanup_tmp.md new file mode 100644 index 0000000..fb93580 --- /dev/null +++ b/.claude/memory/feedback_cleanup_tmp.md @@ -0,0 +1,14 @@ +--- +name: cleanup-tmp-screenshots +description: Очищать устаревшие скриншоты только из /home/isaevea/python/pp/tmp +metadata: + node_type: memory + type: feedback + originSessionId: 3ee145c7-aca2-4844-98fa-84c57114d04a +--- + +После отладочной работы со скриншотами — удалять ненужные файлы из `/home/isaevea/python/pp/tmp`. + +**Why:** Пользователь явно попросил держать tmp чистым от лишних снимков. + +**How to apply:** Когда работа с отладочными снимками завершена — очищать tmp. Только этот путь, не трогать другие директории. diff --git a/.claude/memory/feedback_test_before_telling.md b/.claude/memory/feedback_test_before_telling.md new file mode 100644 index 0000000..ab00b0c --- /dev/null +++ b/.claude/memory/feedback_test_before_telling.md @@ -0,0 +1,14 @@ +--- +name: test-before-telling-user-to-run +description: "Пользователь требует самостоятельно протестировать код перед тем, как говорить ему запустить" +metadata: + node_type: memory + type: feedback + originSessionId: 3ee145c7-aca2-4844-98fa-84c57114d04a +--- + +Никогда не говори пользователю "запустите скрипт" или "выполните команду", пока сам не проверишь, что код работает без ошибок. + +**Why:** Пользователь явно потребовал это правило — он не хочет тратить время на запуск сломанного кода. + +**How to apply:** После написания любого скрипта — сначала запусти его самостоятельно через Bash (с таймаутом если нужно), убедись что нет ошибок, и только потом сообщай пользователю что готово. diff --git a/.claude/memory/project_dickreuter.md b/.claude/memory/project_dickreuter.md new file mode 100644 index 0000000..7b447d8 --- /dev/null +++ b/.claude/memory/project_dickreuter.md @@ -0,0 +1,32 @@ +--- +name: project-dickreuter +description: Как используется dickreuter_poker в проекте +metadata: + node_type: memory + type: project + originSessionId: 3ee145c7-aca2-4844-98fa-84c57114d04a +--- + +Проект использует `/home/isaevea/python/dickreuter_poker` как внешнюю зависимость (добавляется в sys.path). + +**Что используется:** +- `poker.decisionmaker.montecarlo_numpy2.Evaluation` — расчёт equity на CPU/numpy, не нужна GPU +- `poker.scraper.table_scraper.TableScraper` — скрапер стола (шаблоны с сервера dickreuter.com:7778) +- `poker.tools.mongo_manager.MongoManager` — загрузка конфига стола GGPoker с удалённого API + +**Конфиг стола:** `"Official GGPoker 6player"` с сервера `https://dickreuter.com:7778/` +- Логин/пароль: guest/guest (в poker/config.ini) +- API endpoint: POST /get_table?table_name=... + +**Заглушки для работы без GUI:** +- `virtualbox/` в корне проекта — заглушка модуля +- `sys.modules['poker.scraper.table_setup_actions_and_signals']` — stub с CARD_VALUES и CARD_SUITES + +**Статус интеграции (2026-06-17):** +- Monte Carlo ✅ работает +- Scraper запускается ✅, но шаблоны не совпадают с webcam — template matching не работает +- OCR ⚠️ частично — борд-карты читаются, карты героя не всегда видны + +**Why:** Основные проблемы — шаблоны dickreuter созданы из 2560×1440 скриншота, а не webcam-изображения. + +**How to apply:** Для equity — использовать montecarlo_numpy2 напрямую. Для скрапинга — нужна доработка. diff --git a/.claude/memory/project_hardware.md b/.claude/memory/project_hardware.md new file mode 100644 index 0000000..45b52ab --- /dev/null +++ b/.claude/memory/project_hardware.md @@ -0,0 +1,30 @@ +--- +name: project-hardware +description: Железо и настройки камеры для проекта +metadata: + node_type: memory + type: project + originSessionId: 3ee145c7-aca2-4844-98fa-84c57114d04a +--- + +**Камера:** Logitech StreamCam → /dev/video2 (индекс 2). /dev/video0 — UVC Camera (другая). + +**Монитор:** 2×2560×1440 (HDMI-0 и DP-0). GGPoker на одном мониторе. + +**Оптимальные настройки камеры** (автоматически подобраны 2026-06-17, сохранены в settings.json): +- focus=60 (ручной, autofocus=0) +- sharpness=192 +- exposure=500 (ручная, auto_exposure=0) +- resolution_idx=1 (1280×720) + +**Координаты GGPoker окна в webcam-кадре 1280×720:** +- Таблица стола: x=15:760, y=55:685 +- Борд-карты: y1=220, y2=370, x1=330, x2=640 +- Левая карта героя: y=438:478, x=472:530 +- Правая карта героя: y=438:478, x=530:585 + +**Проблема карт героя:** нижняя часть экрана частично выходит за кадр камеры — нужно опустить камеру чуть ниже. + +**Why:** Координаты зависят от положения камеры. При смещении — пересчитывать. + +**How to apply:** Перед работой с координатами — сначала сделать снапшот и проверить актуальность. diff --git a/.claude/memory/project_overview.md b/.claude/memory/project_overview.md new file mode 100644 index 0000000..0283cbb --- /dev/null +++ b/.claude/memory/project_overview.md @@ -0,0 +1,23 @@ +--- +name: project-overview +description: Общий обзор проекта — покер-анализатор для GGPoker через вебкамеру +metadata: + node_type: memory + type: project + originSessionId: 3ee145c7-aca2-4844-98fa-84c57114d04a +--- + +Проект — покер-анализатор: вебкамера снимает монитор с GGPoker, Python распознаёт карты и считает equity. + +**Файлы:** +- `main.py` — просмотр камеры с настройками (trackbars), сохранение в settings.json +- `analyzer.py` — основной пайплайн: webcam → OCR → Monte Carlo → совет +- `webcam_scraper.py` — адаптер для подключения webcam к dickreuter_poker скраперу +- `virtualbox/` — заглушка модуля (dickreuter импортирует virtualbox, он не нужен) +- `files/` — спрайт-лист карт (table_card.png + card.json), не совпадает со стилем GGPoker +- `settings.json` — настройки камеры (автоматически подобраны) +- `tmp/` — все временные файлы, снапшоты + +**Why:** Пользователь анализирует покерные видео/игры в реальном времени. + +**How to apply:** При любой работе с проектом — знать эту структуру. tmp/ для всех временных файлов. diff --git a/.claude/memory/project_tmp_dir.md b/.claude/memory/project_tmp_dir.md new file mode 100644 index 0000000..b03fe3a --- /dev/null +++ b/.claude/memory/project_tmp_dir.md @@ -0,0 +1,14 @@ +--- +name: project-tmp-dir +description: "Все временные файлы (скриншоты, снимки с камеры и т.д.) сохранять в /home/isaevea/python/pp/tmp" +metadata: + node_type: memory + type: project + originSessionId: 3ee145c7-aca2-4844-98fa-84c57114d04a +--- + +Временные файлы проекта хранятся в `/home/isaevea/python/pp/tmp`, а не в `/tmp`. + +**Why:** Пользователь явно указал этот путь. + +**How to apply:** При сохранении любых временных файлов (снимки камеры, снапшоты, промежуточные данные) всегда использовать `/home/isaevea/python/pp/tmp/`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5e874d6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,88 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Purpose + +Poker analysis tool for GGPoker using a Logitech StreamCam pointed at a 2560×1440 monitor. Captures the game via webcam, reads cards via OCR and color detection, calculates equity via Monte Carlo simulation. + +## Running the Scripts + +```bash +# Live camera viewer with settings panel (trackbars) +python3 main.py + +# Poker analyzer — board OCR + Monte Carlo equity +python3 analyzer.py # auto OCR hero cards +python3 analyzer.py Qd Qc # manually specify hero cards (rank+suit) + +# Webcam scraper using dickreuter_poker backend +python3 webcam_scraper.py +``` + +## Architecture + +``` +main.py — Camera viewer with OpenCV trackbars. Loads/saves settings.json. +analyzer.py — Main analysis pipeline: webcam → crop → OCR → Monte Carlo → advice. +webcam_scraper.py — Adapter connecting webcam to dickreuter_poker TableScraper. +virtualbox/ — Stub module: dickreuter_poker imports virtualbox which isn't needed. +files/ — Card sprite sheet (table_card.png + card.json) for future template matching. +settings.json — Persisted camera settings (auto-tuned, do not edit manually). +tmp/ — All temporary files, snapshots, debug images go here. +``` + +### External dependency: dickreuter_poker +Located at `/home/isaevea/python/dickreuter_poker`. Added to `sys.path` at runtime. +- `poker.decisionmaker.montecarlo_numpy2.Evaluation` — Monte Carlo equity calculator (CPU/numpy, no GPU). +- `poker.scraper.table_scraper.TableScraper` — Table state scraper (card templates from `https://dickreuter.com:7778/`). +- `poker.tools.mongo_manager.MongoManager` — Fetches GGPoker table config from remote API. Table name: `"Official GGPoker 6player"`. + +GUI modules of dickreuter_poker (PyQt6) are stubbed out in `webcam_scraper.py` via `sys.modules` injection. + +## Camera Hardware + +- **Device**: Logitech StreamCam → `/dev/video2` (index 2). `/dev/video0` is UVC Camera. +- **Optimal settings** (auto-tuned, saved in `settings.json`): focus=60 (manual), sharpness=192, exposure=500 (manual). +- Qt font issue fix: Liberation Sans fonts copied to `.venv/lib/python3.12/site-packages/cv2/qt/fonts/`. +- Window titles must be ASCII-only — Cyrillic in `cv2.namedWindow()` causes NULL pointer crash. Trackbar labels support Cyrillic. + +## Webcam Coordinate System + +All coordinate constants in `analyzer.py` are in the **1280×720 webcam frame**: + +```python +BOARD_AREA = (220, 370, 330, 640) # y1,y2,x1,x2 — entire flop/turn/river area +HERO_L_AREA = (438, 478, 472, 530) # left hole card +HERO_R_AREA = (438, 478, 530, 585) # right hole card +``` + +These are camera-position-dependent. If camera moves, recalibrate by running a snapshot and checking `tmp/` images. + +In `webcam_scraper.py`, the GGPoker window boundary: +```python +TABLE_X1, TABLE_Y1 = 15, 55 +TABLE_X2, TABLE_Y2 = 760, 685 +``` +This crops the GGPoker client from the webcam frame and scales to 1500×1100 (dickreuter's `CROP_WIDTH × CROP_HEIGHT`). + +## Card Detection + +**Board cards** (`read_board_cards`): BOARD_AREA is divided into 3 equal columns. Top-left corner of each is OCR'd via Tesseract (`tessedit_char_whitelist=23456789TJQKA`). Suit detected by mean red channel (>100 → hearts/diamonds, else spades). + +**Hero cards** (`read_hero_cards`): Color-based suit detection in HSV space — blue hue → ♦, green → ♣, red → ♥, dark → ♠. GGPoker uses emoji-style colored card backgrounds. + +**Tessdata path**: `/home/isaevea/python/dickreuter_poker/tessdata/` + +## Monte Carlo Card Format + +`montecarlo_numpy2.Evaluation.run_evaluation(card1, card2, tablecards, iterations, player_amount)` + +Cards encoded as `[rank, suit]` where rank: 2–14 (A=14), suit: 0=clubs, 1=diamonds, 2=hearts, 3=spades. + +```python +RANK_MAP = {'2':2,...,'T':10,'J':11,'Q':12,'K':13,'A':14} +SUIT_MAP = {'c':0,'d':1,'h':2,'s':3} +``` + +Returns float equity (0.0–1.0). Typical: >0.65 → raise, >0.45 → call, <0.25 → fold. diff --git a/analyzer.py b/analyzer.py new file mode 100644 index 0000000..2f21516 --- /dev/null +++ b/analyzer.py @@ -0,0 +1,578 @@ +""" +GGPoker Analyzer +webcam -> calibrated zones -> OCR cards -> dealer detection -> Monte Carlo equity +""" +import sys, json, cv2, numpy as np +from PIL import Image + +sys.path.insert(0, '/home/isaevea/python/dickreuter_poker') +sys.path.insert(0, '/home/isaevea/python/pp') + +from types import ModuleType +_stub = ModuleType('poker.scraper.table_setup_actions_and_signals') +_stub.CARD_VALUES = "23456789TJQKA"; _stub.CARD_SUITES = "CDHS" +sys.modules['poker.scraper.table_setup_actions_and_signals'] = _stub + +CALIB_FILE = '/home/isaevea/python/pp/calibration.json' +FONT = cv2.FONT_HERSHEY_SIMPLEX +DETECT_EVERY = 8 +LOCK_NEEDED = 5 + +RANK_MAP = {'2':2,'3':3,'4':4,'5':5,'6':6,'7':7,'8':8, + '9':9,'T':10,'J':11,'Q':12,'K':13,'A':14} +TEMPLATES_DIR = '/home/isaevea/python/pp/templates' +CARD_SIZE = (60, 80) # размер для сравнения полной карты +CORNER_SIZE = (30, 50) +RANKS = ['2','3','4','5','6','7','8','9','T','J','Q','K','A'] +SUITS = ['c','d','h','s'] + +_card_templates = None +def load_card_templates(): + global _card_templates + if _card_templates is not None: return _card_templates + _card_templates = {} + for r in RANKS: + for s in SUITS: + key = r + s + img = cv2.imread(f'{TEMPLATES_DIR}/{key}.png', cv2.IMREAD_GRAYSCALE) + if img is not None: + _card_templates[key] = cv2.resize(img, CARD_SIZE) + print(f'[+] Loaded {len(_card_templates)} card templates') + return _card_templates +SUIT_MAP = {'c':0,'d':1,'h':2,'s':3} +SUIT_SYM = {'h':'♥','d':'♦','c':'♣','s':'♠', + 'H':'♥','D':'♦','C':'♣','S':'♠'} +POS_NAMES = {0:'Hero(BTN?)',1:'P1',2:'P2',3:'P3',4:'P4',5:'P5',6:'P6',7:'P7'} + + +# ── Load calibration ────────────────────────────────────────────────────────── +def load_calib(): + with open(CALIB_FILE) as f: + return json.load(f) + +def zone(calib, key): + v = calib.get(key) + return (v['x1'],v['y1'],v['x2'],v['y2']) if v else None + +def crop(frame, key, calib): + r = zone(calib, key) + if r is None: return None + x1,y1,x2,y2 = r + return frame[y1:y2, x1:x2] + + +# ── Camera ──────────────────────────────────────────────────────────────────── +def open_camera(): + cap = cv2.VideoCapture(2) + cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1920) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 1080) + for _ in range(20): cap.read() + return cap + + +# ── OCR rank ───────────────────────────────────────────────────────────────── +_tess = None +def get_tess(): + global _tess + if _tess is None: + from tesserocr import PyTessBaseAPI, PSM, OEM + _tess = PyTessBaseAPI( + path='/home/isaevea/python/dickreuter_poker/tessdata', + psm=PSM.SINGLE_CHAR, oem=OEM.LSTM_ONLY) + _tess.SetVariable('tessedit_char_whitelist','23456789TJQKAtjqka') + return _tess + +_OCR_FIX = {'R':'K','I':'J','0':'Q','O':'Q','1':'J','L':'J','B':'8','G':'9','Z':'2'} + +def _ocr_region(img_bgr): + """Run Tesseract on a BGR image region, return rank char or '?'.""" + try: + from tesserocr import PyTessBaseAPI, PSM, OEM + gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) + _, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) + up = cv2.resize(thresh, (0,0), fx=3, fy=3, interpolation=cv2.INTER_LANCZOS4) + pil = Image.fromarray(up) + with PyTessBaseAPI( + path='/home/isaevea/python/dickreuter_poker/tessdata', + psm=PSM.SINGLE_WORD, oem=OEM.LSTM_ONLY + ) as api: + api.SetVariable('tessedit_char_whitelist', '23456789TJQKAtjqka10') + api.SetImage(pil) + r = api.GetUTF8Text().strip().upper().replace(' ','') + if not r: return '?' + if r.startswith('10') or r == '1O': return 'T' + c = r[0] + c = _OCR_FIX.get(c, c) + if c in '23456789TJQKA': return c + return '?' + except: + return '?' + +def ocr_big_rank(img_bgr): + """Try lower-right (large rank) first, fallback to upper-left (small rank).""" + h, w = img_bgr.shape[:2] + # large central rank (board cards style) + r = _ocr_region(img_bgr[int(h*0.35):, int(w*0.30):]) + if r != '?': + return r + # upper portion (hero cards show small rank at top) + r = _ocr_region(img_bgr[:int(h*0.55), :int(w*0.65)]) + return r + +def match_card(img_bgr): + """ + Rank: OCR (large central digit — handles '10' correctly). + Suit: shape template matching, fallback to color detection. + """ + if not is_card_present(img_bgr): return '??' + rank = ocr_big_rank(img_bgr) + suit = detect_suit_by_shape(img_bgr) + if suit == '?': + suit = detect_suit_ggpoker(img_bgr) + if rank == '?' or suit == '?': return '??' + return rank + suit + +def ocr_rank(img_bgr): return '?' + + +# ── Suit detection ──────────────────────────────────────────────────────────── +_suit_templates = None +def load_suit_templates(): + global _suit_templates + if _suit_templates is not None: return _suit_templates + _suit_templates = {} + for s in SUITS: + img = cv2.imread(f'{TEMPLATES_DIR}/suit_{s}.png', cv2.IMREAD_GRAYSCALE) + if img is not None: + _suit_templates[s] = cv2.resize(img, (30, 30)) + return _suit_templates + +_rank_shape_templates = None +def load_rank_shape_templates(): + global _rank_shape_templates + if _rank_shape_templates is not None: return _rank_shape_templates + _rank_shape_templates = {} + for r in RANKS: + img = cv2.imread(f'{TEMPLATES_DIR}/rank_{r}.png', cv2.IMREAD_GRAYSCALE) + if img is not None: + _rank_shape_templates[r] = cv2.resize(img, (40, 50)) + print(f'[+] Loaded {len(_rank_shape_templates)} rank templates') + return _rank_shape_templates + +def detect_suit_by_shape(img_bgr): + """Match suit symbol area against 4 templates from card files.""" + tpls = load_suit_templates() + if not tpls: return '?' + h, w = img_bgr.shape[:2] + sym = img_bgr[int(h*0.30):int(h*0.52), :int(w*0.42)] + gray = cv2.cvtColor(sym, cv2.COLOR_BGR2GRAY) + _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + binary_r = cv2.resize(binary, (30, 30)) + best_suit, best_score = '?', -1.0 + for s, tpl in tpls.items(): + score = float(cv2.matchTemplate(binary_r, tpl, cv2.TM_CCOEFF_NORMED).max()) + if score > best_score: + best_score, best_suit = score, s + return best_suit if best_score > 0.15 else '?' + +def match_rank_by_shape(img_bgr, suit=None): + """Match rank — top 50% full width for better coverage of large digits.""" + tpls = load_rank_shape_templates() + if not tpls: return '?' + h, w = img_bgr.shape[:2] + rank_area = img_bgr[:int(h*0.50), :] # top half, full width + gray = cv2.cvtColor(rank_area, cv2.COLOR_BGR2GRAY) + _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + binary_r = cv2.resize(binary, (40, 50)) + best_rank, best_score = '?', -1.0 + for r, tpl in tpls.items(): + score = float(cv2.matchTemplate(binary_r, tpl, cv2.TM_CCOEFF_NORMED).max()) + if score > best_score: + best_score, best_rank = score, r + return best_rank if best_score > 0.15 else '?' + +def detect_suit_ggpoker(img_bgr): + """ + GGPoker colored bg: Red=h, Green=c, Blue=d, Dark=s — fast color check. + White bg (classic): fall through to shape matching. + """ + hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) + h_ch, s_ch, v_ch = cv2.split(hsv) + + ch, cw = img_bgr.shape[:2] + cy1, cy2 = int(ch*0.20), int(ch*0.90) + cx1, cx2 = int(cw*0.10), int(cw*0.90) + hc = h_ch[cy1:cy2, cx1:cx2] + sc = s_ch[cy1:cy2, cx1:cx2] + vc = v_ch[cy1:cy2, cx1:cx2] + + mean_v = float(np.mean(vc)) + mean_s = float(np.mean(sc)) + + # GGPoker dark background → spades + if mean_v < 130 and mean_s > 30: + return 's' + + # White/light background → use shape matching + if mean_v > 155 and mean_s < 55: + return detect_suit_by_shape(img_bgr) + + # GGPoker colored background → color buckets + mask = (sc > 60) & (vc > 60) + if mask.sum() < 30: + return detect_suit_by_shape(img_bgr) + + hues = hc[mask] + red = int(np.sum((hues < 12) | (hues > 165))) + green = int(np.sum((hues >= 38) & (hues <= 88))) + blue = int(np.sum((hues >= 89) & (hues <= 148))) + + best = max(red, green, blue) + if best < 10: + return detect_suit_by_shape(img_bgr) + if best == red: return 'h' + if best == green: return 'c' + return 'd' + +detect_suit_board = detect_suit_ggpoker +detect_suit_hero = detect_suit_ggpoker + +def is_card_present(img_bgr): + if img_bgr is None or img_bgr.size == 0: return False + gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) + return float(np.std(gray)) > 18 + + +# ── Card reading ────────────────────────────────────────────────────────────── +def read_card(img_bgr, is_board=True): + if not is_card_present(img_bgr): return '??' + return match_card(img_bgr) + +def read_board(frame, calib): + cards = [] + for i in range(1, 6): + img = crop(frame, f'board_card_{i}', calib) + if img is None: break + c = read_card(img, is_board=True) + if c == '??': break + cards.append(c) + return cards + +def read_hero(frame, calib): + cards = [] + for i in range(1, 3): + img = crop(frame, f'hero_card_{i}', calib) + c = read_card(img, is_board=False) if img is not None else '??' + cards.append(c) + return cards + + +# ── Dealer button detection ─────────────────────────────────────────────────── +_dealer_tpl = None +def load_dealer_template(): + global _dealer_tpl + if _dealer_tpl is None: + img = cv2.imread('/home/isaevea/python/pp/files/D.png', cv2.IMREAD_COLOR) + if img is not None: + _dealer_tpl = img + return _dealer_tpl + +def find_dealer(frame, calib): + """ + Match D.png template in each player zone. + Returns player index or None. + """ + tpl = load_dealer_template() + best_player, best_score = None, 0.0 + th, tw = (tpl.shape[:2] if tpl is not None else (0,0)) + + for i in range(8): + img = crop(frame, f'player_{i}', calib) + if img is None: continue + h, w = img.shape[:2] + + if tpl is not None and w >= tw and h >= th: + # try at multiple scales + for scale in [0.5, 0.7, 1.0, 1.3]: + sw2, sh2 = max(1,int(tw*scale)), max(1,int(th*scale)) + if sw2 > w or sh2 > h: continue + t = cv2.resize(tpl, (sw2, sh2)) + res = cv2.matchTemplate(img, t, cv2.TM_CCOEFF_NORMED) + score = float(res.max()) + if score > best_score: + best_score, best_player = score, i + else: + # fallback: color detection + hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) + mask = cv2.inRange(hsv, (12,120,150),(38,255,255)) + score = float(np.sum(mask>0)) / max(1, mask.size) + if score > best_score: + best_score, best_player = score, i + + return best_player if best_score > 0.35 else None + + +# ── Monte Carlo ─────────────────────────────────────────────────────────────── +def to_mc(card): + if not card or len(card)<2 or '?' in card: return None + r = RANK_MAP.get(card[0].upper()) + s = SUIT_MAP.get(card[1].lower()) + return [r,s] if r and s is not None else None + +def calc_equity(hero, board, n_players=4, iters=3000): + from poker.decisionmaker.montecarlo_numpy2 import Evaluation + c1,c2 = to_mc(hero[0]), to_mc(hero[1]) + if not c1 or not c2: return None + table = [c for c in (to_mc(b) for b in board) if c] + try: return Evaluation().run_evaluation(c1,c2,table,iters,n_players) + except: return None + +def advice(eq): + if eq > 0.65: return 'RAISE / BET' + if eq > 0.45: return 'CALL / CHECK' + if eq > 0.25: return 'CALL (осторожно)' + return 'FOLD' + +def position_name(dealer_idx, player_idx, n=8): + """Determine position name relative to dealer.""" + if dealer_idx is None: return '?' + offset = (player_idx - dealer_idx) % n + names = {0:'BTN',1:'SB',2:'BB',3:'UTG',4:'UTG+1',5:'MP',6:'HJ',7:'CO'} + return names.get(offset, f'+{offset}') + + +# ── Overlay ─────────────────────────────────────────────────────────────────── +def put(img, text, pos, scale, color, thick=2): + x,y=pos + cv2.putText(img,text,(x+1,y+1),FONT,scale,(0,0,0),thick+1) + cv2.putText(img,text,(x,y), FONT,scale,color, thick) + +def card_color(suit): + return (60,60,255) if suit.lower() in('h','d') else (230,230,230) + +def draw_card(img, card, x, y, w=56, h=78): + s = card[1].lower() if len(card)>1 else '' + bg = (30,30,160) if s in('h','d') else (20,20,20) + cv2.rectangle(img,(x,y),(x+w,y+h),bg,-1) + cv2.rectangle(img,(x,y),(x+w,y+h),(160,160,160),1) + if '?' not in card and len(card)==2: + col = card_color(card[1]) + put(img, card[0].upper(), (x+5,y+30), 0.9, col, 2) + put(img, SUIT_SYM.get(card[1],'?'), (x+5,y+56), 0.75, col, 2) + else: + put(img,'?',(x+18,y+46),1.0,(80,80,80),2) + +def draw_overlay(frame, state, calib): + ph,pw = frame.shape[:2] + PX,PY,PW,PH = 8, ph-230, 560, 220 + + ov = frame.copy() + cv2.rectangle(ov,(PX,PY),(PX+PW,PY+PH),(15,15,15),-1) + cv2.addWeighted(ov,0.78,frame,0.22,0,frame) + cv2.rectangle(frame,(PX,PY),(PX+PW,PY+PH),(70,70,70),1) + + y = PY+28 + put(frame,'BOARD',(PX+8,y),0.52,(150,150,150)) + for i,c in enumerate(state['board']+['??']*(5-len(state['board']))): + draw_card(frame, c if i55 else (0,180,255) if pct>35 else (50,50,230) + put(frame,f'Equity {pct:.0f}% {state["advice"]}',(PX+8,y),0.65,col,2) + else: + put(frame,'Жду карты...',(PX+8,y),0.55,(100,100,100)) + + # dealer / position + dealer = state.get('dealer') + pos = state.get('hero_position','?') + if dealer is not None or pos != '?': + info = f'Dealer: P{dealer} Hero pos: {pos}' if dealer is not None else f'Hero pos: {pos}' + put(frame,info,(PX+8,PY+PH-10),0.48,(200,200,80)) + + # lock dot + locked = state.get('locked',False) + cv2.circle(frame,(PX+PW-14,PY+14),6,(0,200,0) if locked else (0,120,220),-1) + + # draw calibration zones scaled to display resolution + dh, dw = frame.shape[:2] + sx, sy = dw/1920, dh/1080 + for key,v in calib.items(): + if 'board' in key: col=(0,180,0) + elif 'hero' in key: col=(0,140,255) + elif 'player' in key: col=(180,0,180) + else: continue + x1,y1 = int(v['x1']*sx), int(v['y1']*sy) + x2,y2 = int(v['x2']*sx), int(v['y2']*sy) + cv2.rectangle(frame,(x1,y1),(x2,y2),col,1) + cv2.putText(frame, key.split('_')[-1], (x1+2,y1+12), + FONT, 0.30, col, 1) + + +# ── Vote buffer ─────────────────────────────────────────────────────────────── +class VoteBuf: + def __init__(self,n=LOCK_NEEDED): + self.n=n; self.h=[] + def push(self,val): + self.h.append(str(val)) + if len(self.h)>self.n*2: self.h=self.h[-self.n*2:] + if len(self.h)>=self.n and len(set(self.h[-self.n:]))==1: + self.h=[]; return val + return None + def reset(self): self.h=[] + + +SUIT_FULL = {'h':'Hearts','d':'Diamonds','c':'Clubs','s':'Spades', + 'H':'Hearts','D':'Diamonds','C':'Clubs','S':'Spades'} + +def draw_info_panel(state): + """Draw a standalone text info window.""" + W, H = 380, 320 + img = np.zeros((H, W, 3), dtype=np.uint8) + img[:] = (25, 25, 25) + + def row(text, y, color=(200,200,200), scale=0.65, thick=1): + cv2.putText(img, text, (12, y), FONT, scale, color, thick) + + row('GGPoker Analyzer', 28, (100,200,100), 0.70, 2) + cv2.line(img, (10,36), (W-10,36), (60,60,60), 1) + + SUIT_LETTER = {'h':'h','d':'d','c':'c','s':'s', + 'H':'h','D':'d','C':'c','S':'s'} + + def fmt_card(c): + if not c or '??' in c or len(c) < 2: return '??' + return f"{c[0].upper()}{SUIT_LETTER.get(c[1],'?')}" + + # Board cards + row('BOARD:', 62, (150,150,150), 0.55) + board = state.get('board', []) + btext = ' '.join(fmt_card(c) for c in board) if board else '---' + row(btext, 90, (255,255,255), 0.80, 2) + + # Hero cards + row('HERO:', 120, (150,150,150), 0.55) + hero = state.get('hero', ['??','??']) + htext = ' '.join(fmt_card(c) for c in hero) + row(htext, 148, (100,200,255), 0.80, 2) + + cv2.line(img, (10,162), (W-10,162), (60,60,60), 1) + + # Equity + advice + eq = state.get('equity') + if eq is not None: + pct = eq * 100 + col = (0,210,0) if pct>55 else (0,180,255) if pct>35 else (60,60,240) + row(f'Equity: {pct:.1f}%', 192, col, 0.75, 2) + row(state.get('advice',''), 222, col, 0.70, 2) + else: + row('Waiting for cards...', 200, (80,80,80), 0.60) + + cv2.line(img, (10,240), (W-10,240), (60,60,60), 1) + + # Position + pos = state.get('hero_position','?') + dealer = state.get('dealer') + row(f'Position: {pos}', 266, + (200,200,80) if pos != '?' else (80,80,80), 0.65, 1) + if dealer is not None: + row(f'Dealer at: P{dealer}', 292, (150,150,150), 0.50) + + # Lock indicator + locked = state.get('locked', False) + dot_col = (0,200,0) if locked else (0,100,200) + cv2.circle(img, (W-20, 20), 7, dot_col, -1) + lbl = 'LOCKED' if locked else 'SCAN' + cv2.putText(img, lbl, (W-70, 16), FONT, 0.38, dot_col, 1) + + # Hint + row('[R]=reset [Q/ESC]=quit', 310, (50,50,50), 0.38) + return img + + +# ── Main ────────────────────────────────────────────────────────────────────── +def main(): + calib = load_calib() + cap = open_camera() + + WIN_CAM = 'Webcam' + WIN_INFO = 'Info' + + cv2.namedWindow(WIN_CAM, cv2.WINDOW_NORMAL | cv2.WINDOW_GUI_NORMAL) + cv2.namedWindow(WIN_INFO, cv2.WINDOW_NORMAL | cv2.WINDOW_GUI_NORMAL) + cv2.resizeWindow(WIN_CAM, 960, 540) + cv2.resizeWindow(WIN_INFO, 380, 320) + + board_buf = VoteBuf(); hero_buf = VoteBuf() + state = {'board':[],'hero':['??','??'],'equity':None, + 'advice':'','dealer':None,'hero_position':'?','locked':False} + fn = 0 + + ret, frame = cap.read() + if ret: + cv2.imshow(WIN_CAM, cv2.resize(frame,(960,540))) + cv2.imshow(WIN_INFO, draw_info_panel(state)) + cv2.waitKey(1) + + print('[+] Running. R=reset, ESC/Q=quit') + + while True: + ret, frame = cap.read() + if not ret: continue + fn += 1 + + if fn % DETECT_EVERY == 0: + board = read_board(frame, calib) + hero = read_hero(frame, calib) + dealer = find_dealer(frame, calib) + + lb = board_buf.push(board) + lh = hero_buf.push(hero) + if lb is not None: state['board'] = lb + if lh is not None: state['hero'] = lh + if dealer is not None: state['dealer'] = dealer + + state['locked'] = bool(state['board'] or + any('??' not in c for c in state['hero'])) + + hero_valid = [c for c in state['hero'] if '??' not in c] + if len(hero_valid) == 2: + eq = calc_equity(hero_valid, state['board']) + if eq is not None: + state['equity'] = eq + state['advice'] = advice(eq) + + if state['dealer'] is not None: + state['hero_position'] = position_name(state['dealer'], 0) + + cv2.imshow(WIN_INFO, draw_info_panel(state)) + + cv2.imshow(WIN_CAM, cv2.resize(frame, (960, 540))) + + k = cv2.waitKey(1) & 0xFF + if k in (27, ord('q')): break + if k == ord('r'): + board_buf.reset(); hero_buf.reset() + state.update({'board':[],'hero':['??','??'],'equity':None, + 'advice':'','locked':False}) + cv2.imshow(WIN_INFO, draw_info_panel(state)) + + try: + cam_vis = cv2.getWindowProperty(WIN_CAM, cv2.WND_PROP_VISIBLE) + info_vis = cv2.getWindowProperty(WIN_INFO, cv2.WND_PROP_VISIBLE) + if cam_vis < 1 and info_vis < 1: break + except: break + + cap.release() + cv2.destroyAllWindows() + if _tess: _tess.End() + +if __name__ == '__main__': + main() diff --git a/calibrate.py b/calibrate.py new file mode 100644 index 0000000..a872260 --- /dev/null +++ b/calibrate.py @@ -0,0 +1,207 @@ +""" +Manual calibration: drag and resize labeled rectangles, then press S to save. +""" +import cv2 +import json + +CALIB_FILE = '/home/isaevea/python/pp/calibration.json' +FONT = cv2.FONT_HERSHEY_SIMPLEX +HANDLE = 10 # corner handle size in px + +# Initial rectangles [name, label, x1,y1,x2,y2, color_BGR] +# Positions are in 1280x720 display space +DEFAULTS = [ + # Board cards + ['board_card_1', 'Board 1', 230, 240, 285, 320, (0, 200, 0)], + ['board_card_2', 'Board 2', 295, 240, 350, 320, (0, 200, 0)], + ['board_card_3', 'Board 3', 360, 240, 415, 320, (0, 200, 0)], + ['board_card_4', 'Board 4', 425, 240, 480, 320, (0, 200, 0)], + ['board_card_5', 'Board 5', 490, 240, 545, 320, (0, 200, 0)], + # Hero cards + ['hero_card_1', 'Hero 1', 395, 440, 450, 510, (255, 140, 0)], + ['hero_card_2', 'Hero 2', 460, 440, 515, 510, (255, 140, 0)], + # Player dealer zones — расставь по позициям игроков вокруг стола + ['player_0', 'P0 Hero', 370, 490, 450, 540, (180, 0, 180)], + ['player_1', 'P1', 170, 410, 250, 460, (180, 0, 180)], + ['player_2', 'P2', 80, 270, 160, 320, (180, 0, 180)], + ['player_3', 'P3', 175, 120, 255, 170, (180, 0, 180)], + ['player_4', 'P4', 370, 80, 450, 130, (180, 0, 180)], + ['player_5', 'P5', 570, 120, 650, 170, (180, 0, 180)], + ['player_6', 'P6', 660, 270, 740, 320, (180, 0, 180)], + ['player_7', 'P7', 570, 410, 650, 460, (180, 0, 180)], +] + +W, H = 1280, 720 # display size + + +def load_existing(): + try: + with open(CALIB_FILE) as f: + data = json.load(f) + # scale stored frame coords (1920x1080) → display coords (1280x720) + sx, sy = W/1920, H/1080 + for row in DEFAULTS: + key = row[0] + if key in data: + v = data[key] + row[2] = int(v['x1']*sx) + row[3] = int(v['y1']*sy) + row[4] = int(v['x2']*sx) + row[5] = int(v['y2']*sy) + except Exception: + pass + + +def save(rects): + sx, sy = 1920/W, 1080/H + data = {} + for name, label, x1,y1,x2,y2, col in rects: + data[name] = { + 'x1': int(x1*sx), 'y1': int(y1*sy), + 'x2': int(x2*sx), 'y2': int(y2*sy), + } + with open(CALIB_FILE, 'w') as f: + json.dump(data, f, indent=2) + print(f'Saved to {CALIB_FILE}') + + +# ── Mouse state ─────────────────────────────────────────────────────────────── +sel = None # index of selected rect +mode = None # 'move' | 'TL'|'TR'|'BL'|'BR'|'T'|'B'|'L'|'R' +mx0 = my0 = 0 +rx0 = ry0 = rx1 = ry1 = 0 + +def hit_handle(x, y, rx1_, ry1_, rx2_, ry2_): + """Return corner/edge name if (x,y) is near a handle, else None.""" + cx = (rx1_+rx2_)//2; cy = (ry1_+ry2_)//2 + checks = [ + ('TL', rx1_, ry1_), ('TR', rx2_, ry1_), + ('BL', rx1_, ry2_), ('BR', rx2_, ry2_), + ('T', cx, ry1_), ('B', cx, ry2_), + ('L', rx1_, cy ), ('R', rx2_, cy ), + ] + for name, hx, hy in checks: + if abs(x-hx) <= HANDLE and abs(y-hy) <= HANDLE: + return name + return None + + +def mouse(event, x, y, flags, rects): + global sel, mode, mx0, my0, rx0, ry0, rx1, ry1 + + if event == cv2.EVENT_LBUTTONDOWN: + # check handles first, then interior + for i, (name, label, x1,y1,x2,y2, col) in enumerate(rects): + h = hit_handle(x, y, x1,y1,x2,y2) + if h: + sel=i; mode=h; mx0=x; my0=y + rx0,ry0,rx1,ry1 = x1,y1,x2,y2 + return + for i, (name, label, x1,y1,x2,y2, col) in enumerate(rects): + if x1<=x<=x2 and y1<=y<=y2: + sel=i; mode='move'; mx0=x; my0=y + rx0,ry0,rx1,ry1 = x1,y1,x2,y2 + return + sel=None + + elif event == cv2.EVENT_MOUSEMOVE and sel is not None and (flags & cv2.EVENT_FLAG_LBUTTON): + dx=x-mx0; dy=y-my0 + r = rects[sel] + if mode=='move': + w=rx1-rx0; h2=ry1-ry0 + r[2]=max(0,rx0+dx); r[3]=max(0,ry0+dy) + r[4]=r[2]+w; r[5]=r[3]+h2 + elif mode=='TL': r[2]=min(rx0+dx,rx1-5); r[3]=min(ry0+dy,ry1-5) + elif mode=='TR': r[4]=max(rx1+dx,rx0+5); r[3]=min(ry0+dy,ry1-5) + elif mode=='BL': r[2]=min(rx0+dx,rx1-5); r[5]=max(ry1+dy,ry0+5) + elif mode=='BR': r[4]=max(rx1+dx,rx0+5); r[5]=max(ry1+dy,ry0+5) + elif mode=='T': r[3]=min(ry0+dy,ry1-5) + elif mode=='B': r[5]=max(ry1+dy,ry0+5) + elif mode=='L': r[2]=min(rx0+dx,rx1-5) + elif mode=='R': r[4]=max(rx1+dx,rx0+5) + + elif event == cv2.EVENT_LBUTTONUP: + mode=None + + +def draw_rect(img, row, is_sel): + name, label, x1,y1,x2,y2, col = row + thick = 3 if is_sel else 2 + border = (255,255,255) if is_sel else col + cv2.rectangle(img, (x1,y1), (x2,y2), border, thick) + + # label background + (tw,th),_ = cv2.getTextSize(label, FONT, 0.45, 1) + cv2.rectangle(img, (x1,y1-th-4), (x1+tw+4,y1), col, -1) + cv2.putText(img, label, (x1+2,y1-3), FONT, 0.45, (0,0,0), 1) + + if is_sel: + # draw handles + cx=(x1+x2)//2; cy=(y1+y2)//2 + for hx,hy in [(x1,y1),(x2,y1),(x1,y2),(x2,y2), + (cx,y1),(cx,y2),(x1,cy),(x2,cy)]: + cv2.rectangle(img,(hx-HANDLE,hy-HANDLE),(hx+HANDLE,hy+HANDLE),(255,255,255),-1) + cv2.rectangle(img,(hx-HANDLE,hy-HANDLE),(hx+HANDLE,hy+HANDLE),(0,0,0),1) + + +def calibrate(): + load_existing() + rects = [list(r) for r in DEFAULTS] + + cap = cv2.VideoCapture(2) + cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1920) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 1080) + for _ in range(20): cap.read() + + WIN = 'Calibration | S=save R=reset Q=quit' + cv2.namedWindow(WIN, cv2.WINDOW_NORMAL | cv2.WINDOW_GUI_NORMAL) + cv2.resizeWindow(WIN, W, H) + cv2.setMouseCallback(WIN, lambda e,x,y,f,p: mouse(e,x,y,f,rects)) + + # init window + ret, frame = cap.read() + if ret: + cv2.imshow(WIN, cv2.resize(frame, (W,H))) + cv2.waitKey(1) + + while True: + ret, frame = cap.read() + if not ret: break + + display = cv2.resize(frame, (W, H)) + + # dim overlay + ov = display.copy() + cv2.rectangle(ov, (0,0),(W,30),(20,20,20),-1) + cv2.addWeighted(ov,0.7,display,0.3,0,display) + cv2.putText(display,'S=save R=reset positions Q=quit', + (10,20),FONT,0.50,(180,180,180),1) + + for i,row in enumerate(rects): + draw_rect(display, row, i==sel) + + cv2.imshow(WIN, display) + k = cv2.waitKey(16) & 0xFF + + if k == ord('q'): break + if k == ord('s'): + save(rects) + cv2.putText(display,'SAVED!',(W//2-40,H//2),FONT,1.2,(0,255,0),3) + cv2.imshow(WIN, display); cv2.waitKey(800) + if k == ord('r'): + for i,row in enumerate(rects): + for j,v in enumerate(DEFAULTS[i][2:6], 2): + row[j] = v + + try: + if cv2.getWindowProperty(WIN, cv2.WND_PROP_VISIBLE) < 1: + break + except: + break + + cap.release() + cv2.destroyAllWindows() + + +if __name__ == '__main__': + calibrate() diff --git a/calibration.json b/calibration.json new file mode 100644 index 0000000..a364bca --- /dev/null +++ b/calibration.json @@ -0,0 +1,92 @@ +{ + "board_card_1": { + "x1": 583, + "y1": 313, + "x2": 700, + "y2": 459 + }, + "board_card_2": { + "x1": 721, + "y1": 312, + "x2": 829, + "y2": 459 + }, + "board_card_3": { + "x1": 852, + "y1": 316, + "x2": 963, + "y2": 456 + }, + "board_card_4": { + "x1": 982, + "y1": 312, + "x2": 1095, + "y2": 454 + }, + "board_card_5": { + "x1": 1107, + "y1": 307, + "x2": 1225, + "y2": 457 + }, + "hero_card_1": { + "x1": 789, + "y1": 670, + "x2": 865, + "y2": 774 + }, + "hero_card_2": { + "x1": 876, + "y1": 661, + "x2": 945, + "y2": 763 + }, + "player_0": { + "x1": 768, + "y1": 603, + "x2": 813, + "y2": 649 + }, + "player_1": { + "x1": 559, + "y1": 585, + "x2": 609, + "y2": 627 + }, + "player_2": { + "x1": 351, + "y1": 442, + "x2": 399, + "y2": 478 + }, + "player_3": { + "x1": 496, + "y1": 235, + "x2": 541, + "y2": 280 + }, + "player_4": { + "x1": 843, + "y1": 181, + "x2": 885, + "y2": 222 + }, + "player_5": { + "x1": 1282, + "y1": 223, + "x2": 1332, + "y2": 270 + }, + "player_6": { + "x1": 1426, + "y1": 426, + "x2": 1476, + "y2": 471 + }, + "player_7": { + "x1": 1209, + "y1": 576, + "x2": 1257, + "y2": 625 + } +} \ No newline at end of file diff --git a/files/D.png b/files/D.png new file mode 100644 index 0000000..872df16 Binary files /dev/null and b/files/D.png differ diff --git a/files/clubs.png b/files/clubs.png new file mode 100644 index 0000000..9571d74 Binary files /dev/null and b/files/clubs.png differ diff --git a/files/diamants.png b/files/diamants.png new file mode 100644 index 0000000..8d67dfa Binary files /dev/null and b/files/diamants.png differ diff --git a/files/hards.png b/files/hards.png new file mode 100644 index 0000000..a827b1c Binary files /dev/null and b/files/hards.png differ diff --git a/files/numbers_ranks.png b/files/numbers_ranks.png new file mode 100644 index 0000000..5e27c35 Binary files /dev/null and b/files/numbers_ranks.png differ diff --git a/files/spades.png b/files/spades.png new file mode 100644 index 0000000..32f8ce5 Binary files /dev/null and b/files/spades.png differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..64dc30c --- /dev/null +++ b/main.py @@ -0,0 +1,24 @@ +import cv2 + +cap = cv2.VideoCapture(2) +cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1920) +cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 1080) + +cv2.namedWindow('webcam', cv2.WINDOW_NORMAL | cv2.WINDOW_GUI_NORMAL) +cv2.resizeWindow('webcam', 1280, 720) + +while True: + ret, frame = cap.read() + if not ret: + break + cv2.imshow('webcam', frame) + if cv2.waitKey(1) == 27: + break + try: + if cv2.getWindowProperty('webcam', cv2.WND_PROP_VISIBLE) < 1: + break + except: + break + +cap.release() +cv2.destroyAllWindows() diff --git a/settings.json b/settings.json new file mode 100644 index 0000000..4466ca3 --- /dev/null +++ b/settings.json @@ -0,0 +1,3 @@ +{ + "resolution_idx": 2 +} diff --git a/templates/2c.png b/templates/2c.png new file mode 100644 index 0000000..4d81540 Binary files /dev/null and b/templates/2c.png differ diff --git a/templates/2d.png b/templates/2d.png new file mode 100644 index 0000000..2867c38 Binary files /dev/null and b/templates/2d.png differ diff --git a/templates/2h.png b/templates/2h.png new file mode 100644 index 0000000..0f1d32e Binary files /dev/null and b/templates/2h.png differ diff --git a/templates/2s.png b/templates/2s.png new file mode 100644 index 0000000..36ef835 Binary files /dev/null and b/templates/2s.png differ diff --git a/templates/3c.png b/templates/3c.png new file mode 100644 index 0000000..b305c44 Binary files /dev/null and b/templates/3c.png differ diff --git a/templates/3d.png b/templates/3d.png new file mode 100644 index 0000000..10e7567 Binary files /dev/null and b/templates/3d.png differ diff --git a/templates/3h.png b/templates/3h.png new file mode 100644 index 0000000..5cc767b Binary files /dev/null and b/templates/3h.png differ diff --git a/templates/3s.png b/templates/3s.png new file mode 100644 index 0000000..e68b752 Binary files /dev/null and b/templates/3s.png differ diff --git a/templates/4c.png b/templates/4c.png new file mode 100644 index 0000000..21262c8 Binary files /dev/null and b/templates/4c.png differ diff --git a/templates/4d.png b/templates/4d.png new file mode 100644 index 0000000..cb2646b Binary files /dev/null and b/templates/4d.png differ diff --git a/templates/4h.png b/templates/4h.png new file mode 100644 index 0000000..6f0cba5 Binary files /dev/null and b/templates/4h.png differ diff --git a/templates/4s.png b/templates/4s.png new file mode 100644 index 0000000..e2448cf Binary files /dev/null and b/templates/4s.png differ diff --git a/templates/5c.png b/templates/5c.png new file mode 100644 index 0000000..115bc87 Binary files /dev/null and b/templates/5c.png differ diff --git a/templates/5d.png b/templates/5d.png new file mode 100644 index 0000000..ed53007 Binary files /dev/null and b/templates/5d.png differ diff --git a/templates/5h.png b/templates/5h.png new file mode 100644 index 0000000..7cedb07 Binary files /dev/null and b/templates/5h.png differ diff --git a/templates/5s.png b/templates/5s.png new file mode 100644 index 0000000..eeca46d Binary files /dev/null and b/templates/5s.png differ diff --git a/templates/6c.png b/templates/6c.png new file mode 100644 index 0000000..bf9f7ff Binary files /dev/null and b/templates/6c.png differ diff --git a/templates/6d.png b/templates/6d.png new file mode 100644 index 0000000..dd08703 Binary files /dev/null and b/templates/6d.png differ diff --git a/templates/6h.png b/templates/6h.png new file mode 100644 index 0000000..4084de1 Binary files /dev/null and b/templates/6h.png differ diff --git a/templates/6s.png b/templates/6s.png new file mode 100644 index 0000000..655e864 Binary files /dev/null and b/templates/6s.png differ diff --git a/templates/7c.png b/templates/7c.png new file mode 100644 index 0000000..dee4f38 Binary files /dev/null and b/templates/7c.png differ diff --git a/templates/7d.png b/templates/7d.png new file mode 100644 index 0000000..fb3ce8f Binary files /dev/null and b/templates/7d.png differ diff --git a/templates/7h.png b/templates/7h.png new file mode 100644 index 0000000..b6654ac Binary files /dev/null and b/templates/7h.png differ diff --git a/templates/7s.png b/templates/7s.png new file mode 100644 index 0000000..9db8509 Binary files /dev/null and b/templates/7s.png differ diff --git a/templates/8c.png b/templates/8c.png new file mode 100644 index 0000000..0aa2010 Binary files /dev/null and b/templates/8c.png differ diff --git a/templates/8d.png b/templates/8d.png new file mode 100644 index 0000000..70dd30e Binary files /dev/null and b/templates/8d.png differ diff --git a/templates/8h.png b/templates/8h.png new file mode 100644 index 0000000..200c136 Binary files /dev/null and b/templates/8h.png differ diff --git a/templates/8s.png b/templates/8s.png new file mode 100644 index 0000000..8da83e9 Binary files /dev/null and b/templates/8s.png differ diff --git a/templates/9c.png b/templates/9c.png new file mode 100644 index 0000000..4f27f79 Binary files /dev/null and b/templates/9c.png differ diff --git a/templates/9d.png b/templates/9d.png new file mode 100644 index 0000000..4716a47 Binary files /dev/null and b/templates/9d.png differ diff --git a/templates/9h.png b/templates/9h.png new file mode 100644 index 0000000..f95bf02 Binary files /dev/null and b/templates/9h.png differ diff --git a/templates/9s.png b/templates/9s.png new file mode 100644 index 0000000..40f8270 Binary files /dev/null and b/templates/9s.png differ diff --git a/templates/Ac.png b/templates/Ac.png new file mode 100644 index 0000000..a04dc47 Binary files /dev/null and b/templates/Ac.png differ diff --git a/templates/Ad.png b/templates/Ad.png new file mode 100644 index 0000000..6ecd9a9 Binary files /dev/null and b/templates/Ad.png differ diff --git a/templates/Ah.png b/templates/Ah.png new file mode 100644 index 0000000..caeaf75 Binary files /dev/null and b/templates/Ah.png differ diff --git a/templates/As.png b/templates/As.png new file mode 100644 index 0000000..a29190b Binary files /dev/null and b/templates/As.png differ diff --git a/templates/Jc.png b/templates/Jc.png new file mode 100644 index 0000000..a999283 Binary files /dev/null and b/templates/Jc.png differ diff --git a/templates/Jd.png b/templates/Jd.png new file mode 100644 index 0000000..912fdbf Binary files /dev/null and b/templates/Jd.png differ diff --git a/templates/Jh.png b/templates/Jh.png new file mode 100644 index 0000000..0078a5d Binary files /dev/null and b/templates/Jh.png differ diff --git a/templates/Js.png b/templates/Js.png new file mode 100644 index 0000000..6b50e03 Binary files /dev/null and b/templates/Js.png differ diff --git a/templates/Kc.png b/templates/Kc.png new file mode 100644 index 0000000..9c5df4d Binary files /dev/null and b/templates/Kc.png differ diff --git a/templates/Kd.png b/templates/Kd.png new file mode 100644 index 0000000..22ce3b8 Binary files /dev/null and b/templates/Kd.png differ diff --git a/templates/Kh.png b/templates/Kh.png new file mode 100644 index 0000000..97a85fe Binary files /dev/null and b/templates/Kh.png differ diff --git a/templates/Ks.png b/templates/Ks.png new file mode 100644 index 0000000..cae4e71 Binary files /dev/null and b/templates/Ks.png differ diff --git a/templates/Qc.png b/templates/Qc.png new file mode 100644 index 0000000..15e7a6e Binary files /dev/null and b/templates/Qc.png differ diff --git a/templates/Qd.png b/templates/Qd.png new file mode 100644 index 0000000..5785c91 Binary files /dev/null and b/templates/Qd.png differ diff --git a/templates/Qh.png b/templates/Qh.png new file mode 100644 index 0000000..7f1b526 Binary files /dev/null and b/templates/Qh.png differ diff --git a/templates/Qs.png b/templates/Qs.png new file mode 100644 index 0000000..824cfa4 Binary files /dev/null and b/templates/Qs.png differ diff --git a/templates/Tc.png b/templates/Tc.png new file mode 100644 index 0000000..30eac70 Binary files /dev/null and b/templates/Tc.png differ diff --git a/templates/Td.png b/templates/Td.png new file mode 100644 index 0000000..1a75a88 Binary files /dev/null and b/templates/Td.png differ diff --git a/templates/Th.png b/templates/Th.png new file mode 100644 index 0000000..018f84b Binary files /dev/null and b/templates/Th.png differ diff --git a/templates/Ts.png b/templates/Ts.png new file mode 100644 index 0000000..22cd6a4 Binary files /dev/null and b/templates/Ts.png differ diff --git a/templates/suit_c.png b/templates/suit_c.png new file mode 100644 index 0000000..1668425 Binary files /dev/null and b/templates/suit_c.png differ diff --git a/templates/suit_d.png b/templates/suit_d.png new file mode 100644 index 0000000..ebf0eae Binary files /dev/null and b/templates/suit_d.png differ diff --git a/templates/suit_h.png b/templates/suit_h.png new file mode 100644 index 0000000..8fb1194 Binary files /dev/null and b/templates/suit_h.png differ diff --git a/templates/suit_s.png b/templates/suit_s.png new file mode 100644 index 0000000..0d8257e Binary files /dev/null and b/templates/suit_s.png differ diff --git a/tmp/.gitkeep b/tmp/.gitkeep new file mode 100644 index 0000000..e69de29