pp/analyzer.py
Egor Isaev d262251e90 dev
2026-06-17 16:58:44 +03:00

565 lines
20 KiB
Python

"""
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
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 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))
display = cv2.resize(frame, (960, 540))
sx, sy = 960/1920, 540/1080
for key, v in calib.items():
if 'board' in key: col = (0, 200, 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(display, (x1,y1), (x2,y2), col, 1)
cv2.putText(display, key.split('_')[-1], (x1+2,y1+11),
FONT, 0.30, col, 1)
cv2.imshow(WIN_CAM, display)
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()