first commit

This commit is contained in:
Egor Isaev 2026-06-17 16:45:21 +03:00
commit 04250bd4b5
76 changed files with 1127 additions and 0 deletions

8
.claude/memory/MEMORY.md Normal file
View File

@ -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, скрапер, статус интеграции

View File

@ -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. Только этот путь, не трогать другие директории.

View File

@ -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 (с таймаутом если нужно), убедись что нет ошибок, и только потом сообщай пользователю что готово.

View File

@ -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 напрямую. Для скрапинга — нужна доработка.

View File

@ -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:** Перед работой с координатами — сначала сделать снапшот и проверить актуальность.

View File

@ -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/ для всех временных файлов.

View File

@ -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/`.

88
CLAUDE.md Normal file
View File

@ -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: 214 (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.01.0). Typical: >0.65 → raise, >0.45 → call, <0.25 fold.

578
analyzer.py Normal file
View File

@ -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 i<len(state['board']) else '??', PX+82+i*62, y-20)
y += 90
put(frame,'HERO',(PX+8,y),0.52,(150,150,150))
for i,c in enumerate(state['hero']):
draw_card(frame, c, PX+82+i*62, y-20)
y += 90
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 (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()

207
calibrate.py Normal file
View File

@ -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()

92
calibration.json Normal file
View File

@ -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
}
}

BIN
files/D.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
files/clubs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
files/diamants.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
files/hards.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
files/numbers_ranks.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
files/spades.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

24
main.py Normal file
View File

@ -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()

3
settings.json Normal file
View File

@ -0,0 +1,3 @@
{
"resolution_idx": 2
}

BIN
templates/2c.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 B

BIN
templates/2d.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 B

BIN
templates/2h.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 B

BIN
templates/2s.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 B

BIN
templates/3c.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 760 B

BIN
templates/3d.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 935 B

BIN
templates/3h.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 B

BIN
templates/3s.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 948 B

BIN
templates/4c.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 B

BIN
templates/4d.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 897 B

BIN
templates/4h.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 B

BIN
templates/4s.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 908 B

BIN
templates/5c.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 727 B

BIN
templates/5d.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 B

BIN
templates/5h.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 B

BIN
templates/5s.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 905 B

BIN
templates/6c.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 B

BIN
templates/6d.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 999 B

BIN
templates/6h.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 B

BIN
templates/6s.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1009 B

BIN
templates/7c.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 652 B

BIN
templates/7d.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 841 B

BIN
templates/7h.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 641 B

BIN
templates/7s.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 B

BIN
templates/8c.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 B

BIN
templates/8d.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
templates/8h.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 807 B

BIN
templates/8s.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
templates/9c.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 B

BIN
templates/9d.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1007 B

BIN
templates/9h.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 793 B

BIN
templates/9s.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 B

BIN
templates/Ac.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 B

BIN
templates/Ad.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 971 B

BIN
templates/Ah.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 B

BIN
templates/As.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 976 B

BIN
templates/Jc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 B

BIN
templates/Jd.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 B

BIN
templates/Jh.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 B

BIN
templates/Js.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 846 B

BIN
templates/Kc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 877 B

BIN
templates/Kd.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
templates/Kh.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 791 B

BIN
templates/Ks.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
templates/Qc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 901 B

BIN
templates/Qd.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
templates/Qh.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 B

BIN
templates/Qs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
templates/Tc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 950 B

BIN
templates/Td.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
templates/Th.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 B

BIN
templates/Ts.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
templates/suit_c.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 B

BIN
templates/suit_d.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

BIN
templates/suit_h.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 B

BIN
templates/suit_s.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 B

0
tmp/.gitkeep Normal file
View File