From 0ed7b5f06d54b053c88c86670257cf88a0e1a084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AE=D1=80=D0=B8=D0=B9=20=D0=A7=D0=B5=D1=80=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE?= Date: Mon, 17 Nov 2025 18:34:55 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D1=80=D0=B5=D0=B4=D0=B2=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BC=D0=B8=D1=82=20shotmap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- get_data.py | 183 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 179 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 5820b83..6175d7a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ /TestJson /logs/* *.venv -*.env \ No newline at end of file +*.env +/shotmaps/* \ No newline at end of file diff --git a/get_data.py b/get_data.py index ec527f8..037c22a 100644 --- a/get_data.py +++ b/get_data.py @@ -16,6 +16,10 @@ import nasio import io, os, platform, time import xml.etree.ElementTree as ET import re +from PIL import Image, ImageDraw, ImageFont +import base64 +from io import BytesIO + parser = argparse.ArgumentParser() parser = argparse.ArgumentParser() @@ -87,6 +91,18 @@ SYNO_URL = os.getenv("SYNO_URL") SYNO_USERNAME = os.getenv("SYNO_USERNAME") SYNO_PASSWORD = os.getenv("SYNO_PASSWORD") SYNO_PATH_VMIX = os.getenv("SYNO_PATH_VMIX") +SYNO_FONT_PATH = os.getenv("SYNO_FONT_PATH") + + +SHOTMAP_SUBDIR = "shotmaps" +# SHOTMAP_DIR = ( +# os.path.join(SYNO_PATH_VMIX, SHOTMAP_SUBDIR) +# if SYNO_PATH_VMIX +# else os.path.join(os.getcwd(), SHOTMAP_SUBDIR) +# ) +# os.makedirs(SHOTMAP_DIR, exist_ok=True) +SHOTMAP_DIR = os.path.join(os.getcwd(), "shotmaps") +os.makedirs(SHOTMAP_DIR, exist_ok=True) CALENDAR = None @@ -602,14 +618,10 @@ def results_consumer(): and GAME_START_DT.date() == datetime.now().date() ): globals()["STATUS"] = "finished_wait" - globals()[ - "CLEAR_OUTPUT_FOR_VMIX" - ] = False # 👈 включаем режим "пустых" данных + globals()["CLEAR_OUTPUT_FOR_VMIX"] = False else: globals()["STATUS"] = "finished_wait" - globals()[ - "CLEAR_OUTPUT_FOR_VMIX" - ] = False # 👈 включаем режим "пустых" данных + globals()["CLEAR_OUTPUT_FOR_VMIX"] = False human_time = datetime.fromtimestamp(switch_at).strftime( "%H:%M:%S" @@ -2238,6 +2250,8 @@ async def team(who: str): "Career_TPlayedTime": format_time(car_T_sec), "Career_TGameCount": car_T_gms, "Career_TStartCount": car_T_starts, + "startNum": stats.get("startNum"), + "photoShotMapGFX": "", } team_rows.append(row) @@ -2295,6 +2309,59 @@ async def team(who: str): key=lambda x: role_priority.get(x.get("startRole", 99), 99), ) + # --- 👇 ДОБАВЛЯЕМ КАРТЫ БРОСКОВ ПО startNum --- # + # Берём play-by-play из текущего game, если он есть + plays = result.get("plays") or [] + + # Собираем множество startNum текущей команды, чтобы не ловить чужих игроков + team_startnums = { + p.get("startNum") + for p in payload.get("starts", []) + if p.get("startRole") == "Player" + } + + # startNum -> список (x, y, is_made) + shots_by_startnum: dict[int, list[tuple[float, float, bool]]] = {} + + for ev in plays: + play_code = ev.get("play") + if play_code not in (2, 3, 5, 6): + continue + + sn = ev.get("startNum") + if sn not in team_startnums: + continue + + x = ev.get("x") + y = ev.get("y") + if x is None or y is None: + continue + sec = ev.get("sec") + period = ev.get("period") + + is_made = play_code in (2, 3) # 2,3 — точные, 5,6 — промахи + shots_by_startnum.setdefault(sn, []).append((x, y, is_made, sec, period)) + + # Рендерим по картинке на каждого startNum + shotmap_paths: dict[int, str] = {} + for sn, points in shots_by_startnum.items(): + # timestamp/версия — просто количество бросков этого игрока. + # Увеличилось кол-во бросков → поменялся путь → vMix не возьмёт картинку из кеша. + version = str(len(points)) + bib = str(sn) # можно заменить на номер игрока, если нужно + + path = get_image(points, bib, version) + shotmap_paths[sn] = path + + # Присоединяем пути к игрокам по startNum + for row in sorted_team: + sn = row.get("startNum") + if sn in shotmap_paths: + row["photoShotMapGFX"] = f"{shotmap_paths[sn]}" + else: + # если бросков не было — оставляем пустую строку + row["photoShotMapGFX"] = "" + return sorted_team @@ -3245,6 +3312,110 @@ async def games_online(): return todays_games + +def get_image(points, bib, count_point): + """ + points: список кортежей (x, y, is_made) + x, y — координаты из API, где (0,0) = центр кольца + is_made — True (play 2,3) или False (play 5,6) + bib: startNum/номер игрока + count_point: строка-версия (анти-кэш vMix) + """ + + if not points: + return "" + + # # --- подложка --- + # try: + # base_image = Image.open(COURT_IMAGE_PATH).convert("RGBA") + # except Exception as e: + # logger.warning( + # f"[shotmap] не удалось открыть COURT_IMAGE_PATH={COURT_IMAGE_PATH}: {e}" + # ) + base_image = Image.new("RGBA", (1500, 2800), (0, 0, 0, 0)) + + width, height = base_image.size # ожидаем 1500 x 2800 + draw = ImageDraw.Draw(base_image) + + # === ДИАПАЗОН КООРДИНАТ ИЗ API === + COURT_WIDTH_UNITS = 150.0 # по X + COURT_LENGTH_UNITS = 280.0 # по Y + + # масштаб: пиксели на 1 API-юнит + scale_x = height / COURT_LENGTH_UNITS + scale_y = width / COURT_WIDTH_UNITS + + # === ЦЕНТР КОЛЬЦА В PILLOW-КООРДИНАТАХ (ОТ ВЕРХНЕГО ЛЕВОГО УГЛА) === + HOOP_X_PX = 750 + HOOP_Y_PX = 157.5 + + def to_px(x, y): + """ + (0,0) из API → (HOOP_X_PX, HOOP_Y_PX) на картинке. + x > 0 — вправо, y > 0 — вниз (вглубь площадки). + Если нужно, чтобы y шёл вверх — поменяй + на -. + """ + px = int(HOOP_X_PX - x * scale_x) + py = int(HOOP_Y_PX + y * scale_y) + return px, py + + point_radius = 10 + + # try: + nasio_font = nasio.load_bio( + user=SYNO_USERNAME, + password=SYNO_PASSWORD, + nas_ip=SYNO_URL, + nas_port="443", + path=SYNO_FONT_PATH, + ) + if nasio_font: + font = ImageFont.truetype(nasio_font, 18) + # except Exception: + # font = ImageFont.load_default() + + for x_raw, y_raw, is_made, sec, period in points: + try: + x = float(x_raw) + y = float(y_raw) + except (TypeError, ValueError): + continue + + px, py = to_px(x, y) + + # кружок + bbox = ( + px - point_radius, + py - point_radius, + px + point_radius, + py + point_radius, + ) + color = (0, 255, 0, 255) if is_made else (255, 0, 0, 255) + draw.ellipse(bbox, fill=color) + + # подпись координат (для отладки) + label = f"{x:.2f}; {y:.2f}" + text_x = px + point_radius + 4 + text_y = py - point_radius - 4 + label = f"Q{period}" + + # тень + draw.text((text_x + 1, text_y + 1), label, font=font, fill=(0, 0, 0, 255)) + # текст + draw.text((text_x, text_y), label, font=font, fill=(255, 255, 255, 255)) + + filename = f"shots_{bib}_{count_point}.png" + path = os.path.join(SHOTMAP_DIR, filename) + + try: + base_image.save(path, "PNG") + except Exception as e: + logger.warning(f"[shotmap] не удалось сохранить {path}: {e}") + return "" + + return path + + if __name__ == "__main__": uvicorn.run( "get_data:app", host="0.0.0.0", port=8000, reload=True, log_level="debug"