first commit
8
.claude/memory/MEMORY.md
Normal 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, скрапер, статус интеграции
|
||||||
14
.claude/memory/feedback_cleanup_tmp.md
Normal 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. Только этот путь, не трогать другие директории.
|
||||||
14
.claude/memory/feedback_test_before_telling.md
Normal 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 (с таймаутом если нужно), убедись что нет ошибок, и только потом сообщай пользователю что готово.
|
||||||
32
.claude/memory/project_dickreuter.md
Normal 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 напрямую. Для скрапинга — нужна доработка.
|
||||||
30
.claude/memory/project_hardware.md
Normal 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:** Перед работой с координатами — сначала сделать снапшот и проверить актуальность.
|
||||||
23
.claude/memory/project_overview.md
Normal 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/ для всех временных файлов.
|
||||||
14
.claude/memory/project_tmp_dir.md
Normal 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
@ -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.
|
||||||
578
analyzer.py
Normal 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
@ -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
@ -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
|
After Width: | Height: | Size: 2.6 KiB |
BIN
files/clubs.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
files/diamants.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
files/hards.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
files/numbers_ranks.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
files/spades.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
24
main.py
Normal 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
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"resolution_idx": 2
|
||||||
|
}
|
||||||
BIN
templates/2c.png
Normal file
|
After Width: | Height: | Size: 744 B |
BIN
templates/2d.png
Normal file
|
After Width: | Height: | Size: 934 B |
BIN
templates/2h.png
Normal file
|
After Width: | Height: | Size: 755 B |
BIN
templates/2s.png
Normal file
|
After Width: | Height: | Size: 947 B |
BIN
templates/3c.png
Normal file
|
After Width: | Height: | Size: 760 B |
BIN
templates/3d.png
Normal file
|
After Width: | Height: | Size: 935 B |
BIN
templates/3h.png
Normal file
|
After Width: | Height: | Size: 728 B |
BIN
templates/3s.png
Normal file
|
After Width: | Height: | Size: 948 B |
BIN
templates/4c.png
Normal file
|
After Width: | Height: | Size: 737 B |
BIN
templates/4d.png
Normal file
|
After Width: | Height: | Size: 897 B |
BIN
templates/4h.png
Normal file
|
After Width: | Height: | Size: 685 B |
BIN
templates/4s.png
Normal file
|
After Width: | Height: | Size: 908 B |
BIN
templates/5c.png
Normal file
|
After Width: | Height: | Size: 727 B |
BIN
templates/5d.png
Normal file
|
After Width: | Height: | Size: 893 B |
BIN
templates/5h.png
Normal file
|
After Width: | Height: | Size: 715 B |
BIN
templates/5s.png
Normal file
|
After Width: | Height: | Size: 905 B |
BIN
templates/6c.png
Normal file
|
After Width: | Height: | Size: 800 B |
BIN
templates/6d.png
Normal file
|
After Width: | Height: | Size: 999 B |
BIN
templates/6h.png
Normal file
|
After Width: | Height: | Size: 787 B |
BIN
templates/6s.png
Normal file
|
After Width: | Height: | Size: 1009 B |
BIN
templates/7c.png
Normal file
|
After Width: | Height: | Size: 652 B |
BIN
templates/7d.png
Normal file
|
After Width: | Height: | Size: 841 B |
BIN
templates/7h.png
Normal file
|
After Width: | Height: | Size: 641 B |
BIN
templates/7s.png
Normal file
|
After Width: | Height: | Size: 853 B |
BIN
templates/8c.png
Normal file
|
After Width: | Height: | Size: 817 B |
BIN
templates/8d.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
templates/8h.png
Normal file
|
After Width: | Height: | Size: 807 B |
BIN
templates/8s.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
templates/9c.png
Normal file
|
After Width: | Height: | Size: 787 B |
BIN
templates/9d.png
Normal file
|
After Width: | Height: | Size: 1007 B |
BIN
templates/9h.png
Normal file
|
After Width: | Height: | Size: 793 B |
BIN
templates/9s.png
Normal file
|
After Width: | Height: | Size: 1008 B |
BIN
templates/Ac.png
Normal file
|
After Width: | Height: | Size: 780 B |
BIN
templates/Ad.png
Normal file
|
After Width: | Height: | Size: 971 B |
BIN
templates/Ah.png
Normal file
|
After Width: | Height: | Size: 769 B |
BIN
templates/As.png
Normal file
|
After Width: | Height: | Size: 976 B |
BIN
templates/Jc.png
Normal file
|
After Width: | Height: | Size: 672 B |
BIN
templates/Jd.png
Normal file
|
After Width: | Height: | Size: 837 B |
BIN
templates/Jh.png
Normal file
|
After Width: | Height: | Size: 635 B |
BIN
templates/Js.png
Normal file
|
After Width: | Height: | Size: 846 B |
BIN
templates/Kc.png
Normal file
|
After Width: | Height: | Size: 877 B |
BIN
templates/Kd.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
templates/Kh.png
Normal file
|
After Width: | Height: | Size: 791 B |
BIN
templates/Ks.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
templates/Qc.png
Normal file
|
After Width: | Height: | Size: 901 B |
BIN
templates/Qd.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
templates/Qh.png
Normal file
|
After Width: | Height: | Size: 896 B |
BIN
templates/Qs.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
templates/Tc.png
Normal file
|
After Width: | Height: | Size: 950 B |
BIN
templates/Td.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
templates/Th.png
Normal file
|
After Width: | Height: | Size: 890 B |
BIN
templates/Ts.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
templates/suit_c.png
Normal file
|
After Width: | Height: | Size: 270 B |
BIN
templates/suit_d.png
Normal file
|
After Width: | Height: | Size: 286 B |
BIN
templates/suit_h.png
Normal file
|
After Width: | Height: | Size: 265 B |
BIN
templates/suit_s.png
Normal file
|
After Width: | Height: | Size: 269 B |