CoolFace
Apppublic

Lifeinhockey/T5_fine_tuning

sourceHugging Faceapache-2.0updated 23h agoView on Hugging Face
1likes
App README

Welcome to Streamlit!

Edit /src/streamlit_app.py to customize this app to your heart's desire. :heart:

If you have any questions, checkout our documentation and community forums.

AK:

MzM0MDUzOTItMjRhNy00ZGFmLThjODktZTdjMjM2NGRkMDZkOjI0OTQ2ZjhhLTQ3ZWEtNDkxOC1iZmMzLWJlOTVmOGZkOWI3Mg==


@title 1. Зависимости, пути, ONNX, YOLO, константы (первая ячейка каждой сессии)

import warnings, logging, os

1. Отключаем все стандартные предупреждения Python

warnings.filterwarnings("ignore")

2. Отключаем логи и WARNING от Ultralytics (откуда летит спам про 'source')

logging.getLogger("ultralytics").setLevel(logging.ERROR) logging.getLogger("ultralytics.trackers").setLevel(logging.ERROR) logging.getLogger("ultralytics.engine").setLevel(logging.ERROR)

3. Отключаем verbose глобально для Ultralytics

os.environ["YOLO_VERBOSE"] = "False" try: from ultralytics import settings settings.update({"verbose": False}) except Exception: pass

print("🔇 Предупреждения и логи Ultralytics отключены")

!pip install -q ultralytics onnxruntime-gpu lap supervision

import os, sys, cv2, json, time import torch from ultralytics import YOLO from tqdm.notebook import tqdm from collections import deque

DEVICE = 'cuda' if torch.cuda.is_available() else 'cpu'

--- Re-ID (OSNet): клон + путь в sys.path

if not os.path.isdir('/content/deep-person-reid'): !git clone -q https://github.com/KaiyangZhou/deep-person-reid.git /content/deep-person-reid if '/content/deep-person-reid' not in sys.path: sys.path.append('/content/deep-person-reid')

--- ONNX TrackNet: восстановление с Google Drive (опционально)

USEDRIVE = False # True False, если Drive не нужен if USEDRIVE: try: from google.colab import drive drive.mount('/content/drive') for src in ('/content/drive/MyDrive/tracknetsingle.onnx', '/content/drive/MyDrive/tracknet.onnx'): if os.path.exists(src) and not os.path.exists('/content/tracknet.onnx'): !cp {src} /content/tracknet.onnx print('✅ ONNX восстановлен с Drive:', src) except Exception as e: print('⚠️ Drive не смонтирован:', type(e).name_)

--- классы и константы

CLSBALL, CLSGK, CLSPLAYER, CLSREF = 0, 1, 2, 3 PERSONCLASSES = (CLSGK, CLSPLAYER, CLSREF) CONF, IOU = 0.1, 0.7 # 0.15, 0.7 MAXFRAMES = 50000 EMBEDEVERY = 12 LOSTHORIZON = 150 GATEPX = 160 REIDSIMTHRESH = 0.72 COLORSIMTHRESH = 0.55 PERSONHEIGHTM = 1.8 MAXSTEPM = 2.0 SPEEDEMAALPHA = 0.25 MAX_DET = 26 #1000 # максимальное количество обнаружений на изображение

VIDEOPATH = '/content/football1.mp4'

VIDEOPATH = '/content/1213640.mp4' # Переворот проекции на макет поля в направлении лево - прво, верх - низ

VIDEOPATH = '/content/0bfacc0.mp4'

VIDEOPATH = '/content/573e610.mp4'

VIDEOPATH = '/content/798b450.mp4'

VIDEOPATH = '/content/54745b0.mp4'

VIDEOPATH = '/content/5384381.mp4'

VIDEOPATH = '/content/a9f16c0.mp4'

VIDEOPATH = '/content/e624c90.mp4'

VIDEOPATH = '/content/0a2d9b1.mp4'

VIDEOPATH = '/content/42ba340.mp4'

VIDEOPATH = '/content/744b270.mp4'

VIDEOPATH = '/content/55c9d10.mp4'

assert os.path.exists(VIDEOPATH), f"Видео не найдено: {VIDEOPATH}" OUTPUTDIR = '/content/output' os.makedirs(OUTPUTDIR, existok=True) OUTVIDEO = os.path.join(OUTPUTDIR, 'tracked.mp4') TRACKNETONNX = '/content/tracknetsingle.onnx' if os.path.exists('/content/tracknetsingle.onnx') \ else '/content/tracknet.onnx' print(f"✅ Загружена модель tracknet_single.onnx")

================== Экспорт модели .pt в TensorRT =========================================

MODELPT = '/content/modelgorsky003.pt' MODELENGINE = MODELPT.replace('.pt', '.engine') # /content/modelgorsky_003.engine

def exporttotensorrt(ptpath, enginepath, imgsz=640, half=True): """ Экспорт YOLO .pt -> TensorRT .engine (FP16) с оптимизацией под T4. Если файл .engine уже существует, пропускаем. """ if os.path.exists(enginepath): print(f"✅ TensorRT engine уже существует: {enginepath}") return engine_path

print(f"⏳ Экспорт {ptpath} -> TensorRT (FP16) ... это займёт ~5–10 минут.") # Временно загружаем модель для экспорта modeltemp = YOLO(ptpath) # Экспортируем с параметрами: половина точности, фиксированный размер, устройство cuda modeltemp.export( format='engine', imgsz=imgsz, half=half, # FP16 batch=1, # фиксированный батч для экономии памяти при экспорте device=DEVICE, # явно указываем устройство simplify=True, # упрощение графа workspace=4, # 4 ГБ RAM для оптимизации (можно увеличить, если есть) verbose=False ) # Файл создаётся с именем {ptpath.replace('.pt', '.engine')} # Проверим, что создался if os.path.exists(enginepath): print(f"✅ TensorRT engine создан: {enginepath}") else: # иногда Ultralytics добавляет суффикс, пробуем найти possible = ptpath.replace('.pt', 'half.engine') if half else ptpath.replace('.pt', '.engine') if os.path.exists(possible): os.rename(possible, enginepath) print(f"✅ TensorRT engine переименован: {enginepath}") else: raise FileNotFoundError(f"❌ Не удалось найти экспортированный engine. Искали {enginepath} и {possible}") # Освобождаем память del modeltemp torch.cuda.emptycache() return enginepath

===============================================================================================

Использование модели .pt без экспорта в TensorRT

model = YOLO('/content/modelgorsky003.pt')

Экспортируем (если надо)

enginepath = exporttotensorrt(MODELPT, MODEL_ENGINE, imgsz=640, half=True)

Загружаем модель из .engine (если он есть) или из .pt

if os.path.exists(enginepath): print("🚀 Загрузка модели из TensorRT:", enginepath) model = YOLO(enginepath) MODELFORMAT = "tensorrtfp16" else: print("⚠️ TensorRT не найден, загрузка из .pt:", MODELPT) model = YOLO(MODELPT) MODELFORMAT = "pytorch_pt"

Применяем параметры инференса (они действительны для любого формата)

model.overrides['conf'] = CONF #0.25 # порог уверенности при обнаружении объектов (confidence threshold) model.overrides['iou'] = IOU #0.45 # порог IoU для Non-Maximum Suppression (NMS), управляет подавлением дублирующих друг друга ограничивающих рамок (bbox). Чем выше порог, тем больше перекрытия разрешено, и тем больше боксов останется. model.overrides['agnosticnms'] = False # определяет, учитывать ли класс объекта при подавлении. False (по умолчанию) – NMS применяется отдельно для каждого класса. Тогда боксы разных классов могут перекрываться и не будут удалены, даже если их IoU высок. model.overrides['maxdet'] = MAX_DET # 26 # максимальное количество обнаружений на изображение

НЕ вызываем model.to(DEVICE) — для .engine это ошибка.

Устройство будем указывать при каждом predict (device=0)

print(f"✅ Модель загружена на {DEVICE}, формат: {MODEL_FORMAT}")

Прогреваем модель несколькими прогонами

print(f"Прогрев модели с device=0 на GPU") warmup = torch.zeros((1, 3, 640, 640), dtype=torch.float32).to(DEVICE) for i in range(5): = model.predict(warmup, conf=model.overrides['conf'], iou=model.overrides['iou'], maxdet=model.overrides['maxdet'], device=0, verbose=False) torch.cuda.synchronize() torch.cuda.emptycache()

--- самопроверка

import onnxruntime, lap try: import torchreid; reidok = True except Exception: reidok = False print(f'✅ Ячейка 1: {DEVICE} | ONNX: {os.path.exists(TRACKNETONNX)} | torchreid: {reidok}')

expected_names = { 0: "ball", 1: "goalkeeper", 2: "player", 3: "referee" }

print("model.names:", model.names)

for idx, name in expected_names.items(): assert model.names.get(idx, "").lower() == name, f"Класс {idx} != {name}"

@title 2 ячейка. numpy 1.26.4 pin + torchreid (OSNet) + BoT-SORT конфиг + TrackNet + утилиты

!pip install -q "numpy==1.26.4" yacs gdown

import numpy as np print('✅ numpy', np._version) import torchreid print('✅ torchreid', torchreid.version_)

--- кастомный конфиг BoT-SORT (параметры, проверенные на футбольных видео)

BOTSORTYAML = '/content/custombotsort.yaml' if not os.path.exists(BOTSORTYAML): with open(BOTSORTYAML, 'w') as f: f.write("""trackertype: botsort trackhighthresh: 0.4 tracklowthresh: 0.15 newtrackthresh: 0.2 trackbuffer: 150 matchthresh: 0.8 gmcmethod: sparseOptFlow """)

--- OSNet Re-ID (лёгкий, для восстановления идентичности)

class OSNetReID: def _init(self, device=DEVICE): try: self.ext = torchreid.utils.FeatureExtractor(modelname='osnetx05', imagesize=(256,128), device=device) self.name = 'osnetx05' except Exception: self.ext = torchreid.utils.FeatureExtractor(modelname='osnetx10', imagesize=(256,128), device=device) self.name = 'osnetx10' def call_(self, crops): crops = [c for c in crops if c is not None and getattr(c, 'size', 0) > 0] if not crops: return np.zeros((0, 512), np.float32) t = self.ext(crops) t = torch.nn.functional.normalize(t, p=2, dim=1) return t.detach().cpu().numpy().astype(np.float32)

--- цветовая гистограмма формы (fallback при сомнении Re-ID)

def colorhist(crop): h, w, = crop.shape c = crop[int(h0.1):int(h0.9), int(w0.2):int(w0.8)] # центр: меньше фона hsv = cv2.cvtColor(c, cv2.COLORBGR2HSV) hist = cv2.calcHist([hsv], [0,1], None, [16,8], [0,180,0,256]) cv2.normalize(hist, hist, alpha=1.0, normtype=cv2.NORM_L1) return hist.flatten().astype(np.float32)

def colorsim(a, b): return 1.0 - cv2.compareHist(a, b, cv2.HISTCMPBHATTACHARYYA)

--- TrackNet (ball) ONNX (исправленный для multi-channel output)

class TrackNetBall: def _init(self, path): prov = ['CUDAExecutionProvider','CPUExecutionProvider'] if DEVICE=='cuda' else ['CPUExecutionProvider'] self.sess = onnxruntime.InferenceSession(path, providers=prov) sh = self.sess.getinputs()[0].shape self.inname = self.sess.getinputs()[0].name self.C = sh[1] if isinstance(sh[1], int) else 3 self.H = sh[2] if isinstance(sh[2], int) else 288 self.W = sh[3] if isinstance(sh[3], int) else 512

# Выводим форму выхода для отладки outsh = self.sess.getoutputs()[0].shape print(f"ℹ️ TrackNet input: {sh} | output: {out_sh}")

def _call(self, frame): small = cv2.resize(frame, (self.W, self.H)).astype(np.float32)/255.0 x = small.transpose(2,0,1) if self.C > 3: x = np.tile(x, (self.C//3,1,1))[:self.C] out = np.asarray(self.sess.run(None, {self.inname: x[None]})[0], np.float32)

# --- Умный reshape: достаем 2D heatmap (H, W) из любого формата out = np.squeeze(out) # убираем batch-измерение, если оно есть (1, ...) if out.ndim == 3: if out.shape[0] <= 4: # Формат Channel-First: (C, H, W) -> берем 0-й канал heat = out[0] else: # Формат Channel-Last: (H, W, C) -> берем 0-й канал heat = out[:, :, 0] elif out.ndim == 2: heat = out # Уже (H, W) else: # Fallback: просто берем первые HW элементов heat = out.reshape(-1)[:self.Hself.W].reshape(self.H, self.W)

if heat.max() > 1.0 or heat.min() < 0.0: # logits -> sigmoid heat = 1.0/(1.0+np.exp(-heat)) mask = heat > 0.5 if not mask.any(): if heat.max() < 0.3: return None mask = heat > 0.5heat.max() ys, xs = np.nonzero(mask) return (xs.mean()/self.Wframe.shape[1], ys.mean()/self.H*frame.shape[0])

def puttext(frame, text, org, scale=0.45, color=(0,0,0)): #cv2.putText(frame, text, org, cv2.FONTHERSHEYSIMPLEX, scale, (255,255,255), 3, cv2.LINEAA) cv2.putText(frame, text, org, cv2.FONTHERSHEYSIMPLEX, scale, color, 3, cv2.LINEAA) cv2.putText(frame, text, org, cv2.FONTHERSHEYSIMPLEX, scale, color, 1, cv2.LINEAA)

print('✅ Ячейка 2 готова: OSNet через torchreid')

@title 4. Конфигурация гибридного эксперимента

import os

print("⚙️ Настройка параметров эксперимента...\n")

==================== ПАРАМЕТРЫ ОТЛАДОЧНОГО ПРОГОНА ====================

SEGMENTSTARTFRAME = 0 # Начальный кадр для анализа NUMDEBUGFRAMES = 750 # 300 # Количество кадров для отладки (300 = 12 секунд при 25 FPS) FRAME_STRIDE = 1 # Шаг кадров (1 = каждый кадр, 2 = каждый второй)

==================== ПУТИ К ФАЙЛАМ И КЭШАМ ====================

CACHEDIR = '/content/cache' os.makedirs(CACHEDIR, exist_ok=True)

DETECTIONSCACHE = os.path.join(CACHEDIR, 'detections.npz') BASELINETRACKSPATH = os.path.join(OUTPUTDIR, 'baselinetracks.json') HOMOGRAPHYDIR = os.path.join(OUTPUTDIR, 'homography') os.makedirs(HOMOGRAPHYDIR, existok=True)

HYBRIDTRACKSPATH = os.path.join(OUTPUTDIR, 'hybridtracks.json') HYBRIDMETRICSPATH = os.path.join(OUTPUTDIR, 'hybridmetrics.json') ABMETRICSPATH = os.path.join(OUTPUTDIR, 'abmetrics.json')

==================== ФЛАГИ ИСПОЛЬЗОВАНИЯ КЭША ====================

USEDETECTIONSCACHE = False # True = использовать кэш детекций, False = пересчитывать SAVEDEBUGFRAMES = True # Сохранять debug-кадры для визуализации SAVEDEBUGVIDEO = False # Сохранять debug-видео (медленно, только для финальной проверки)

==================== КЛАССЫ ДЛЯ ТРЕКИНГА ====================

Для трекинга используем всех людей (игроки + вратари + судьи)

ALLPERSONCLASSES = (CLSPLAYER, CLSGK, CLS_REF)

Для статистики эффективности - только игроки и вратари

PLAYERCLASSES = (CLSPLAYER, CLS_GK)

==================== ПАРАМЕТРЫ ГИБРИДНОГО ТРЕКЕРА ====================

Motion-часть на плоскости (в метрах)

MAXPLANEDISTM = 2.5 # Максимальная дистанция на плоскости для ассоциации (метры) MAXPLANESPEEDMPS = 10.0 # Максимальная скорость игрока (м/с) PLANEKALMANPROCESSNOISE = 1.0 # Шум процесса для Kalman на плоскости PLANEKALMANMEASUREMENTNOISE = 1.0 # Шум измерений для Kalman

Appearance-часть в кадре (bbox IoU + цвет)

MINIOUFORASSOCIATION = 0.1 # Минимальный IoU для рассмотрения ассоциации USECOLORAPPEARANCE = True # Использовать цветовые гистограммы COLORWEIGHT = 0.1 # Вес цветового сходства в cost matrix

Веса для гибридной cost matrix

WPLANE = 0.6 # Вес дистанции на плоскости WIOU = 0.3 # Вес IoU bbox W_COLOR = 0.1 # Вес цветового сходства

==================== RE-ID ПАРАМЕТРЫ (ПОКА ОТКЛЮЧЕНЫ) ====================

USEREID = False # Включить OSNet Re-ID (требует много памяти) USEOSNET = False # Использовать OSNet embeddings REIDEMBEDDIM = 512 # Размерность embedding REIDSIMTHRESH = 0.72 # Порог сходства для восстановления трека

==================== УПРАВЛЕНИЕ ПОТЕРЯННЫМИ ТРЕКАМИ ====================

LOSTTRACKHORIZON = 150 # Сколько кадров хранить потерянный трек SHORTTRACKMINFRAMES = 5 # Минимальная длина трека, чтобы не считать его мусором NEWTRACKCONFIRMFRAMES = 3 # Сколько кадров детекция должна быть стабильной, чтобы создать трек

==================== ГОМОГРАФИЯ И КАЛИБРОВКА ====================

HOMOGRAPHYRANSACTHRESH = 3.0 # Порог RANSAC для оценки гомографии (пиксели) HOMOGRAPHYMININLIERS = 8 # Минимальное число inliers для валидной гомографии HOMOGRAPHYMAXREPROJERROR = 8.0 # Максимальная reprojection error (пиксели) HOMOGRAPHYSMOOTH_ALPHA = 0.2 # Коэффициент сглаживания EMA для гомографии

==================== ВИЗУАЛИЗАЦИЯ ====================

VISBBOXTHICKNESS = 2 # Толщина bbox VISTEXTSCALE = 0.5 # Размер текста VISTRACKIDCOLOR = True # Цвет по trackid VISSHOWFOOTPOINT = True # Показывать точку проекции ног VISSHOWPROJECTIONFLAG = True # Показывать флаг projection_valid

==================== РАЗМЕРЫ ПОЛЯ (ФУТБОЛ) ====================

PITCHLENGTHM = 105.0 # Длина поля в метрах PITCHWIDTHM = 68.0 # Ширина поля в метрах

==================== МЯЧ И POSSESSION ====================

USETRACKNET = True # Использовать TrackNet для мяча BALLTRACKSMOOTHWINDOW = 5 # Окно сглаживания траектории мяча (кадры) POSSESSIONDISTANCEM = 2.0 # Расстояние для определения possession (метры) POSSESSIONFRAMESTHRESHOLD = 3 # Сколько кадров мяч должен быть рядом с игроком

==================== ВЫВОД КОНФИГУРАЦИИ ====================

print("📋 Конфигурация эксперимента:") print(f" Отрезок видео: кадры {SEGMENTSTARTFRAME} - {SEGMENTSTARTFRAME + NUMDEBUGFRAMES}") print(f" Шаг кадров: {FRAMESTRIDE}") print(f" Кэш детекций: {'включен' if USEDETECTIONSCACHE else 'отключен'}") print(f" Re-ID: {'OSNet' if USEOSNET else 'отключен'}") print(f" Цветовое сходство: {'включено' if USECOLORAPPEARANCE else 'отключено'}") print(f"\n⚖️ Веса cost matrix:") print(f" WPLANE (дистанция на плоскости): {WPLANE}") print(f" WIOU (bbox IoU): {WIOU}") print(f" WCOLOR (цвет): {WCOLOR}") print(f"\n📏 Гейты ассоциации:") print(f" MAXPLANEDISTM: {MAXPLANEDISTM} м") print(f" MAXPLANESPEEDMPS: {MAXPLANESPEEDMPS} м/с") print(f" MINIOUFORASSOCIATION: {MINIOUFORASSOCIATION}") print(f"\n🎯 Пути к файлам:") print(f" DETECTIONSCACHE: {DETECTIONSCACHE}") print(f" BASELINETRACKSPATH: {BASELINETRACKSPATH}") print(f" HYBRIDTRACKSPATH: {HYBRIDTRACKSPATH}")

print("\n✅ Ячейка 4 готова: конфигурация гибридного эксперимента задана")

@title 5. Утилиты памяти и чтения видео

import gc import os import time import cv2 import numpy as np import torch

=====================================================

1. Проверка, что ячейка 4 уже выполнена

=====================================================

requiredvars = [ 'VIDEOPATH', 'SEGMENTSTARTFRAME', 'NUMDEBUGFRAMES', 'FRAMESTRIDE', 'OUTPUTDIR', 'CACHE_DIR' ]

for varname in requiredvars: assert varname in globals(), f"❌ Не найдена переменная {varname}. Сначала выполните ячейку 4."

=====================================================

2. Приведение типов

=====================================================

VIDEOPATH = str(VIDEOPATH) SEGMENTSTARTFRAME = int(SEGMENTSTARTFRAME) NUMDEBUGFRAMES = int(NUMDEBUGFRAMES) FRAMESTRIDE = max(1, int(FRAMESTRIDE))

Если SAVEDEBUGFRAMES не задан, по умолчанию включим

SAVEDEBUGFRAMES = bool(globals().get('SAVEDEBUGFRAMES', True))

DEBUGDIR = os.path.join(OUTPUTDIR, 'debugframes') if SAVEDEBUGFRAMES: os.makedirs(DEBUGDIR, exist_ok=True)

os.makedirs(CACHEDIR, existok=True) os.makedirs(OUTPUTDIR, existok=True)

=====================================================

3. Освобождение памяти

=====================================================

def freememory(): """ Очистка Python GC и кэша CUDA. Вызывать после тяжёлых блоков: детекции, трекер, визуализация. """ gc.collect() if torch.cuda.isavailable(): torch.cuda.empty_cache()

=====================================================

4. Метаданные видео

=====================================================

def getvideometa(videopath): """ Возвращает базовые метаданные видео: fps, ширина, высота, число кадров, длительность. """ cap = cv2.VideoCapture(videopath) if not cap.isOpened(): raise RuntimeError(f"❌ Не удалось открыть видео: {video_path}")

fps = cap.get(cv2.CAPPROPFPS)

# Защита от битого FPS if fps is None or np.isnan(fps) or fps <= 0: fps = 25.0

width = int(cap.get(cv2.CAPPROPFRAMEWIDTH)) height = int(cap.get(cv2.CAPPROPFRAMEHEIGHT)) totalframes = int(cap.get(cv2.CAPPROPFRAMECOUNT))

if totalframes < 0: totalframes = 0

durationsec = totalframes / fps if fps > 0 and total_frames > 0 else 0.0

cap.release()

return { 'path': videopath, 'fps': float(fps), 'width': width, 'height': height, 'totalframes': totalframes, 'durationsec': float(duration_sec) }

VIDEOMETA = getvideometa(VIDEOPATH) VIDEOFPS = float(VIDEOMETA['fps']) FRAMEW = int(VIDEOMETA['width']) FRAMEH = int(VIDEOMETA['height'])

Временной шаг между обрабатываемыми кадрами.

Используется позже в Kalman-фильтрах.

DTSECONDS = FRAMESTRIDE / VIDEOFPS if VIDEOFPS > 0 else 1.0 / 25.0

=====================================================

5. Seek по видео

=====================================================

def seektoframe(cap, frameidx): """ Пытается быстро перейти к нужному кадру. Если seek неточный, доходит grab'ами. """ frameidx = max(0, int(frame_idx))

cap.set(cv2.CAPPROPPOSFRAMES, frameidx) current = int(cap.get(cv2.CAPPROPPOS_FRAMES))

# Если улетели дальше нужного кадра, начинаем с 0 и идём последовательно if current > frameidx: cap.set(cv2.CAPPROPPOSFRAMES, 0) current = 0

# Дотягиваем до нужного кадра while current < frame_idx: if not cap.grab(): break current += 1

return current

=====================================================

6. Генератор кадров

=====================================================

def itervideoframes( videopath=VIDEOPATH, start=SEGMENTSTARTFRAME, count=NUMDEBUGFRAMES, stride=FRAME_STRIDE ): """ Генератор кадров видео.

Параметры:

  • start: стартовый кадр;
  • count: сколько кадров нужно отдать после применения stride;
  • stride: шаг кадров.

Возвращает:

  • frame_id: абсолютный номер кадра в видео;
  • frame: numpy BGR кадр.

Важно:

  • кадры не хранятся в памяти;
  • видео читается потоково. """ start = max(0, int(start)) count = max(0, int(count)) stride = max(1, int(stride))

if count == 0: return

cap = cv2.VideoCapture(videopath) if not cap.isOpened(): raise RuntimeError(f"❌ Не удалось открыть видео: {videopath}")

seektoframe(cap, start)

yielded = 0 rawtoread = count * stride

try: for i in range(rawtoread): ret, frame = cap.read() if not ret: break

frame_id = start + i

if i % stride == 0: yield frame_id, frame yielded += 1

finally: cap.release()

=====================================================

7. Чтение одного кадра по индексу

=====================================================

def readframeatindex(videopath, frameidx): """ Читает один кадр по абсолютному индексу. Используется позже для калибровки и debug-проверок. """ frameidx = int(frame_idx)

cap = cv2.VideoCapture(video_path) if not cap.isOpened(): return None

seektoframe(cap, frame_idx) ret, frame = cap.read() cap.release()

if not ret: return None

return frame

=====================================================

8. Ожидаемые frame_id для сегмента

=====================================================

def getexpectedframeids( start=SEGMENTSTARTFRAME, count=NUMDEBUGFRAMES, stride=FRAMESTRIDE ): """ Возвращает ожидаемые frameid для сегмента. Фактические frameid могут быть короче, если видео закончится раньше. """ start = int(start) count = int(count) stride = max(1, int(stride))

return list(range(start, start + count * stride, stride))

=====================================================

9. Сохранение debug-кадров

=====================================================

def savedebugframe(frameid, frame, prefix='frame'): """ Сохраняет debug-кадр в OUTPUTDIR/debugframes. Используется для визуальных проверок детекций, проекций, треков. """ if not SAVEDEBUG_FRAMES: return None

os.makedirs(DEBUGDIR, existok=True)

outpath = os.path.join( DEBUGDIR, f"{prefix}{int(frameid):06d}.jpg" )

cv2.imwrite( outpath, frame, [int(cv2.IMWRITEJPEG_QUALITY), 85] )

return out_path

=====================================================

10. Вывод метаданных

=====================================================

print("🎬 Метаданные видео:") print(f" Путь: {VIDEOMETA['path']}") print(f" FPS: {VIDEOMETA['fps']:.2f}") print(f" Разрешение: {VIDEOMETA['width']}x{VIDEOMETA['height']}") print(f" Всего кадров: {VIDEOMETA['totalframes']}") print(f" Длительность: {VIDEOMETA['durationsec']:.1f} сек") print() print("⚙️ Параметры сегмента:") print(f" Стартовый кадр: {SEGMENTSTARTFRAME}") print(f" Кадров для анализа: {NUMDEBUGFRAMES}") print(f" Шаг кадров: {FRAMESTRIDE}") print(f" DTSECONDS: {DTSECONDS:.4f}") print(f" Debug-кадры: {'включены' if SAVEDEBUG_FRAMES else 'отключены'}")

=====================================================

11. Быстрый тест чтения кадров

=====================================================

testcount = min(5, NUMDEBUG_FRAMES)

print() print(f"🧪 Тест чтения {test_count} кадров...")

t0 = time.perf_counter()

nread = 0 lastframeid = None lastshape = None

for frameid, frame in itervideoframes(count=testcount): nread += 1 lastframeid = frameid last_shape = frame.shape

elapsed = time.perf_counter() - t0

if n_read == 0: raise RuntimeError("❌ Не удалось прочитать ни одного кадра из сегмента.")

print(f"✅ Прочитано кадров: {nread}") print(f" Последний frameid: {lastframeid}") print(f" Shape последнего кадра: {lastshape}") print(f" Время чтения: {elapsed*1000:.1f} ms") print(f" Средняя скорость чтения: {nread / elapsed:.1f} кадров/сек")

free_memory()

print() print("✅ Ячейка 5 готова: VideoReader, VIDEOMETA, DTSECONDS, free_memory")

@title 6. Покадровая детекция и сохранение кэша

import os import time import json import numpy as np import torch from tqdm.notebook import tqdm

=====================================================

1. Проверка зависимостей от предыдущих ячеек

=====================================================

requiredvars = [ 'model', 'VIDEOPATH', 'CACHEDIR', 'DETECTIONSCACHE', 'SEGMENTSTARTFRAME', 'NUMDEBUGFRAMES', 'FRAME_STRIDE' ]

for varname in requiredvars: assert varname in globals(), f"❌ Не найдена переменная {varname}. Сначала выполните ячейки 1, 4, 5."

assert callable(globals().get('itervideoframes')), "❌ Не найдена функция itervideoframes из ячейки 5." assert callable(globals().get('readframeatindex')), "❌ Не найдена функция readframeatindex из ячейки 5." assert callable(globals().get('freememory')), "❌ Не найдена функция freememory из ячейки 5."

=====================================================

2. Параметры детекции

=====================================================

USEDETECTIONSCACHE = bool(globals().get('USEDETECTIONSCACHE', False))

DETECTIONCONF = float(globals().get('CONF', CONF)) DETECTIONIOU = float(globals().get('IOU', IOU))

Рекомендуется 40, даже если в ячейке 1 стоит 28.

DETECTIONMAXDET = int(globals().get('DETECTIONMAXDET', 40))

Входной размер для YOLO.

DETECTIONIMGSZ = int(globals().get('DETECTIONIMGSZ', 640))

Сколько реальных кадров прогнать до основного цикла.

WARMUPFRAMES = int(globals().get('WARMUPFRAMES', 3))

DETECTIONSMETAPATH = DETECTIONSCACHE.replace('.npz', 'meta.json')

INFERENCEDEVICE = 0 if torch.cuda.isavailable() else 'cpu'

os.makedirs(CACHEDIR, existok=True)

=====================================================

3. Функция детекции одного кадра

=====================================================

def runyolodetect(frame): """ Возвращает:

  • xyxy: [N, 4] float32
  • class_id: [N] int8
  • conf: [N] float32 """ results = model.predict( frame, conf=DETECTIONCONF, iou=DETECTIONIOU, maxdet=DETECTIONMAXDET, imgsz=DETECTIONIMGSZ, agnosticnms=False, device=INFERENCEDEVICE, verbose=False )

r = results[0]

if r.boxes is None or len(r.boxes) == 0: return ( np.zeros((0, 4), dtype=np.float32), np.zeros((0,), dtype=np.int8), np.zeros((0,), dtype=np.float32) )

xyxy = r.boxes.xyxy.detach().cpu().numpy().astype(np.float32) class_id = r.boxes.cls.detach().cpu().numpy().astype(np.int8) conf = r.boxes.conf.detach().cpu().numpy().astype(np.float32)

return xyxy, class_id, conf

=====================================================

4. Warmup детектора на реальном кадре

=====================================================

def warmupdetector(): """ Прогревает TensorRT/YOLO на реальном кадре, чтобы первый рабочий кадр не был медленным. """ if WARMUPFRAMES <= 0: return

print(f"🔥 Warmup детектора: {WARMUP_FRAMES} прогонов...")

frame = readframeatindex(VIDEOPATH, SEGMENTSTARTFRAME)

if frame is None: print("⚠️ Не удалось прочитать кадр для warmup. Пропускаем warmup.") return

for in range(WARMUPFRAMES): = runyolodetect(frame) if torch.cuda.isavailable(): torch.cuda.synchronize()

if torch.cuda.isavailable(): torch.cuda.emptycache()

print("✅ Warmup завершён")

=====================================================

5. Печать статистики детекций

=====================================================

def printdetectionstats(frameids, classids): frameids = np.asarray(frameids) classids = np.asarray(classids)

if len(frame_ids) == 0: print(" ⚠️ В кэше нет детекций.") return

uniqueframes = np.unique(frameids) numframes = len(uniqueframes) numdetections = len(classids)

print(f" Кадров с детекциями: {numframes}") print(f" Всего детекций: {numdetections}") print(f" Среднее число детекций на кадр: {numdetections / max(1, numframes):.2f}")

if len(classids) > 0: uniqueclasses, counts = np.unique(classids, returncounts=True)

print(" Распределение по классам:")

for clsid, cnt in zip(uniqueclasses, counts): clsid = int(clsid)

if 'model' in globals() and hasattr(model, 'names'): clsname = model.names.get(clsid, str(clsid)) else: clsname = str(cls_id)

print(f" {cls_name}: {int(cnt)}")

=====================================================

6. Попытка использовать существующий кэш

=====================================================

cache_loaded = False

if USEDETECTIONSCACHE and os.path.exists(DETECTIONS_CACHE): try: print("📦 Найден кэш детекций. Пробуем использовать его...")

with np.load(DETECTIONSCACHE) as data: requiredkeys = { 'frameid', 'classid', 'x1', 'y1', 'x2', 'y2', 'conf', 'cx', 'cy', 'footx', 'footy' }

if not requiredkeys.issubset(set(data.files)): raise ValueError(f"В кэше отсутствуют ключи: {requiredkeys - set(data.files)}")

frameidcached = data['frameid'] classidcached = data['classid']

print("✅ Кэш детекций корректен.") printdetectionstats(frameidcached, classidcached)

cache_loaded = True

except Exception as e: print(f"⚠️ Кэш повреждён или неполный: {e}") print("Будет запущена повторная детекция.") cache_loaded = False

=====================================================

7. Если кэш не используется или повреждён — детектим заново

=====================================================

if not cacheloaded: warmupdetector()

frameids = [] classids = []

x1list = [] y1list = [] x2list = [] y2list = []

conf_list = []

cxlist = [] cylist = []

footxlist = [] footylist = []

processedframes = 0 totaldetections = 0

t0 = time.perf_counter()

pbar = tqdm( itervideoframes( videopath=VIDEOPATH, start=SEGMENTSTARTFRAME, count=NUMDEBUGFRAMES, stride=FRAMESTRIDE ), total=NUMDEBUG_FRAMES, desc="Детекция" )

try: for frameid, frame in pbar: xyxy, cls, conf = runyolo_detect(frame)

n = len(cls)

if n > 0: x1 = xyxy[:, 0] y1 = xyxy[:, 1] x2 = xyxy[:, 2] y2 = xyxy[:, 3]

cx = (x1 + x2) 0.5 cy = (y1 + y2) 0.5

# Для игроков/вратарей/судей later будем проецировать foot-точку. # Для мяча лучше использовать cx/cy. footx = cx.copy() footy = y2.copy()

frameids.extend([frameid] * n) class_ids.extend(cls.tolist())

x1list.extend(x1.tolist()) y1list.extend(y1.tolist()) x2list.extend(x2.tolist()) y2list.extend(y2.tolist())

conf_list.extend(conf.tolist())

cxlist.extend(cx.tolist()) cylist.extend(cy.tolist())

footxlist.extend(footx.tolist()) footylist.extend(footy.tolist())

processedframes += 1 totaldetections += n

if processedframes % 20 == 0: pbar.setpostfix( { "det": totaldetections, "det/f": f"{totaldetections / max(1, processed_frames):.1f}" } )

finally: pbar.close()

elapsed = time.perf_counter() - t0

# ===================================================== # 8. Преобразование в numpy-массивы # ===================================================== frameidarr = np.array(frameids, dtype=np.int32) classidarr = np.array(classids, dtype=np.int8)

x1arr = np.array(x1list, dtype=np.float32) y1arr = np.array(y1list, dtype=np.float32) x2arr = np.array(x2list, dtype=np.float32) y2arr = np.array(y2list, dtype=np.float32)

confarr = np.array(conflist, dtype=np.float32)

cxarr = np.array(cxlist, dtype=np.float32) cyarr = np.array(cylist, dtype=np.float32)

footxarr = np.array(footxlist, dtype=np.float32) footyarr = np.array(footylist, dtype=np.float32)

# ===================================================== # 9. Сохранение кэша # ===================================================== np.savezcompressed( DETECTIONSCACHE, frameid=frameidarr, classid=classidarr, x1=x1arr, y1=y1arr, x2=x2arr, y2=y2arr, conf=confarr, cx=cxarr, cy=cyarr, footx=footxarr, footy=footy_arr )

# ===================================================== # 10. Сохранение meta-информации # ===================================================== cachemeta = { 'videopath': VIDEOPATH, 'segmentstartframe': int(SEGMENTSTARTFRAME), 'numframesrequested': int(NUMDEBUGFRAMES), 'framestride': int(FRAMESTRIDE), 'processedframes': int(processedframes), 'totaldetections': int(totaldetections), 'detectionconf': DETECTIONCONF, 'detectioniou': DETECTIONIOU, 'detectionmaxdet': DETECTIONMAXDET, 'detectionimgsz': DETECTIONIMGSZ, 'modelformat': str(globals().get('MODELFORMAT', 'unknown')), 'createdunixtime': time.time(), 'elapsedseconds': elapsed, 'fps': processedframes / elapsed if elapsed > 0 else 0.0, 'classnames': { str(k): str(v) for k, v in getattr(model, 'names', {}).items() } }

with open(DETECTIONSMETAPATH, 'w', encoding='utf-8') as f: json.dump(cachemeta, f, indent=2, ensureascii=False)

# ===================================================== # 11. Отчёт # ===================================================== print() print("✅ Детекции обработаны и сохранены.") print(f" Кэш: {DETECTIONSCACHE}") print(f" Meta: {DETECTIONSMETAPATH}") print(f" Обработано кадров: {processedframes}") print(f" Всего детекций: {totaldetections}") print(f" Время детекции: {elapsed:.2f} сек") print(f" Скорость: {processedframes / elapsed:.1f} кадров/сек" if elapsed > 0 else " Скорость: N/A") print()

printdetectionstats(frameidarr, classidarr)

free_memory()

print() print("✅ Ячейка 6 готова: кэш детекций находится в DETECTIONS_CACHE")

@title 7. Загрузка детекций из кэша и группировка по кадрам

import os import json import numpy as np from collections import defaultdict

=====================================================

1. Проверка зависимостей

=====================================================

requiredvars = [ 'DETECTIONSCACHE', 'SEGMENTSTARTFRAME', 'NUMDEBUGFRAMES', 'FRAME_STRIDE' ]

for varname in requiredvars: assert varname in globals(), f"❌ Не найдена переменная {varname}. Сначала выполните ячейку 4."

assert os.path.exists(DETECTIONSCACHE), ( f"❌ Файл детекций не найден: {DETECTIONSCACHE}. Сначала выполните ячейку 6." )

assert callable(globals().get('getexpectedframeids')), ( "❌ Не найдена функция getexpectedframeids из ячейки 5." )

=====================================================

2. Чтение кэша детекций

=====================================================

REQUIREDKEYS = { 'frameid', 'classid', 'x1', 'y1', 'x2', 'y2', 'conf', 'cx', 'cy', 'footx', 'foot_y' }

print("📦 Загрузка кэша детекций...")

with np.load(DETECTIONSCACHE) as data: missingkeys = REQUIREDKEYS - set(data.files) if missingkeys: raise KeyError(f"❌ В кэше детекций отсутствуют ключи: {missing_keys}")

DETFRAMEID = data['frameid'].astype(np.int32) DETCLASSID = data['classid'].astype(np.int8)

DETX1 = data['x1'].astype(np.float32) DETY1 = data['y1'].astype(np.float32) DETX2 = data['x2'].astype(np.float32) DETY2 = data['y2'].astype(np.float32)

DET_CONF = data['conf'].astype(np.float32)

DETCX = data['cx'].astype(np.float32) DETCY = data['cy'].astype(np.float32)

DETFOOTX = data['footx'].astype(np.float32) DETFOOTY = data['footy'].astype(np.float32)

print("✅ Кэш детекций загружен.")

=====================================================

3. Чтение meta-файла

=====================================================

DETECTIONSMETAPATH = DETECTIONSCACHE.replace('.npz', 'meta.json') DETECTIONS_META = None

if os.path.exists(DETECTIONSMETAPATH): try: with open(DETECTIONSMETAPATH, 'r', encoding='utf-8') as f: DETECTIONSMETA = json.load(f) except Exception as e: print(f"⚠️ Не удалось прочитать meta-файл: {e}") DETECTIONSMETA = None

=====================================================

4. Имена классов

=====================================================

if 'model' in globals() and hasattr(model, 'names'): CLASSNAMES = { int(k): str(v) for k, v in model.names.items() } elif DETECTIONSMETA is not None and 'classnames' in DETECTIONSMETA: CLASSNAMES = { int(k): str(v) for k, v in DETECTIONSMETA['classnames'].items() } else: CLASSNAMES = { 0: '0', 1: '1', 2: '2', 3: '3' }

=====================================================

5. Классы для быстрых фильтров

=====================================================

CLSBALL = int(globals().get('CLSBALL', 0)) CLSGK = int(globals().get('CLSGK', 1)) CLSPLAYER = int(globals().get('CLSPLAYER', 2)) CLSREF = int(globals().get('CLSREF', 3))

=====================================================

6. Формирование списка кадров для трекинга

=====================================================

Ожидаемые frame_id задаёт ячейка 5.

expectedframeids = getexpectedframeids( start=SEGMENTSTARTFRAME, count=NUMDEBUGFRAMES, stride=FRAMESTRIDE )

Если есть meta с числом реально обработанных кадров, обрезаем список.

if DETECTIONSMETA is not None and 'processedframes' in DETECTIONSMETA: processedframes = int(DETECTIONSMETA['processedframes']) expectedframeids = expectedframeids[:processed_frames]

Если по какой-то причине список пуст, используем кадры из кэша.

if len(expectedframeids) == 0: expectedframeids = sorted(set(DETFRAMEID.tolist()))

TRACKINGFRAMEIDS = [int(x) for x in expectedframeids]

=====================================================

7. Индексы детекций для каждого кадра

=====================================================

Чтобы не хранить лишние Python-объекты, храним индексы в глобальных numpy-массивах.

frametoindices = defaultdict(list)

for idx, frameid in enumerate(DETFRAMEID.tolist()): frametoindices[int(frameid)].append(idx)

DETECTIONSFRAMEINDICES = { int(frameid): np.asarray(frametoindices.get(int(frameid), []), dtype=np.int32) for frameid in TRACKINGFRAME_IDS }

Проверка: есть ли детекции, которые не попали в ожидаемый сегмент.

detectedframeids = set(frametoindices.keys()) expectedframeidsset = set(TRACKINGFRAMEIDS) outsidesegment = detectedframeids - expectedframeids_set

if len(outsidesegment) > 0: print( f"⚠️ Найдено {len(outsidesegment)} кадров с детекциями вне текущего сегмента. " "Они не будут использоваться в TRACKINGFRAMEIDS." )

=====================================================

8. Функции доступа к детекциям

=====================================================

def getdetectionindices(frameid): """ Возвращает индексы детекций для конкретного frameid. """ return DETECTIONSFRAMEINDICES.get(int(frame_id), np.empty(0, dtype=np.int32))

def getdetectionsforframe(frameid, class_ids=None): """ Возвращает список детекций для кадра.

Параметры:

  • frame_id: номер кадра;
  • classids: список/кортеж классов, например (CLSPLAYER, CLS_GK).

Формат одной детекции: { 'frameid': int, 'classid': int, 'classname': str, 'bbox': [x1, y1, x2, y2], 'conf': float, 'cx': float, 'cy': float, 'footx': float, 'footy': float } """ idxs = getdetectionindices(frameid)

if len(idxs) == 0: return []

if classids is not None: classidsarr = np.asarray(list(classids), dtype=np.int8) mask = np.isin(DETCLASSID[idxs], classidsarr) idxs = idxs[mask]

detections = []

for idx in idxs: i = int(idx)

clsid = int(DETCLASS_ID[i])

detections.append( { 'frameid': int(DETFRAMEID[i]), 'classid': clsid, 'classname': CLASSNAMES.get(clsid, str(clsid)), 'bbox': [ float(DETX1[i]), float(DETY1[i]), float(DETX2[i]), float(DETY2[i]) ], 'conf': float(DETCONF[i]), 'cx': float(DETCX[i]), 'cy': float(DETCY[i]), 'footx': float(DETFOOTX[i]), 'footy': float(DETFOOTY[i]) } )

return detections

def iterdetections(classids=None): """ Генератор:

  • frame_id
  • список детекций для этого кадра

Использовать так: for frameid, dets in iterdetections(): ... """ for frameid in TRACKINGFRAMEIDS: yield frameid, getdetectionsforframe(frameid, classids=classids)

def getframeidswithclass(classid): """ Возвращает frameid, где есть хотя бы одна детекция указанного класса. """ classid = int(classid) result = []

for frameid in TRACKINGFRAMEIDS: idxs = getdetectionindices(frameid) if len(idxs) == 0: continue

if np.any(DETCLASSID[idxs] == classid): result.append(frameid)

return result

=====================================================

9. Статистика по загруженным детекциям

=====================================================

totaltrackingframes = len(TRACKINGFRAMEIDS) nonemptyframes = 0

totaldetections = len(DETFRAME_ID)

playercounts = [] ballframes = 0 gkframes = 0 refframes = 0

for frameid in TRACKINGFRAMEIDS: idxs = getdetectionindices(frameid)

if len(idxs) > 0: nonemptyframes += 1

cls = DETCLASSID[idxs]

playercount = int(np.sum((cls == CLSPLAYER) | (cls == CLSGK))) playercounts.append(player_count)

if np.any(cls == CLSBALL): ballframes += 1

if np.any(cls == CLSGK): gkframes += 1

if np.any(cls == CLSREF): refframes += 1

avgdetectionsperframe = totaldetections / max(1, totaltrackingframes) avgplayersperframe = float(np.mean(playercounts)) if len(player_counts) > 0 else 0.0

=====================================================

10. Отчёт

=====================================================

print() print("📊 Статистика загруженных детекций:") print(f" Кадров для трекинга: {totaltrackingframes}") print(f" Кадров с детекциями: {nonemptyframes}") print(f" Кадров без детекций: {totaltrackingframes - nonemptyframes}") print(f" Всего детекций: {totaldetections}") print(f" Среднее число детекций на кадр: {avgdetectionsperframe:.2f}") print(f" Среднее число players+goalkeepers на кадр: {avgplayersperframe:.2f}") print() print(f" Кадров с мячом: {ballframes}") print(f" Кадров с goalkeeper: {gkframes}") print(f" Кадров с referee: {refframes}") print() print("🎯 Доступные функции:") print(" getdetectionsforframe(frameid)") print(" iterdetections(classids=None)") print(" getframeidswithclass(classid)") print(" getdetectionindices(frameid)")

=====================================================

11. Быстрая проверка

=====================================================

testframeid = TRACKINGFRAMEIDS[0] testdets = getdetectionsforframe(testframeid)

print() print(f"🧪 Быстрая проверка frameid={testframeid}:") print(f" Детекций: {len(testdets)}")

if len(testdets) > 0: d = testdets[0] print(" Пример первой детекции:") print(f" class: {d['classname']}") print(f" bbox: {[round(v, 1) for v in d['bbox']]}") print(f" conf: {d['conf']:.3f}") print(f" cx/cy: {d['cx']:.1f} / {d['cy']:.1f}") print(f" foot: {d['footx']:.1f} / {d['foot_y']:.1f}")

=====================================================

12. Предупреждения

=====================================================

if gkframes < int(0.2 * totaltracking_frames): print() print("⚠️ Внимание: goalkeeper детектируется менее чем в 20% кадров.") print(" Это не блокирует текущий этап, но позже нужно проверить:") print(" - качество детекции вратаря;") print(" - не путается ли он с player;") print(" - есть ли вратарь в кадре на этом отрезке.")

if ballframes < int(0.2 * totaltracking_frames): print() print("⚠️ Внимание: мяч детектируется менее чем в 20% кадров.") print(" Для текущего этапа не критично, но позже нужно улучшить ball-tracking.")

if callable(globals().get('freememory')): freememory()

print() print("✅ Ячейка 7 готова: DETECTIONSFRAMEINDICES, TRACKINGFRAMEIDS, getdetectionsfor_frame")

@title 8. Визуальная проверка детекций

import os import cv2 import numpy as np from IPython.display import display, Image

=====================================================

1. Проверка зависимостей от предыдущих ячеек

=====================================================

requiredvars = [ 'VIDEOPATH', 'OUTPUTDIR', 'TRACKINGFRAMEIDS', 'getdetectionsforframe', 'itervideoframes' ]

for varname in requiredvars: assert varname in globals(), f"❌ Не найдена переменная/функция {varname}. Сначала выполните предыдущие ячейки."

=====================================================

2. Параметры визуализации

=====================================================

VISNUMFRAMES = int(globals().get('VISNUMFRAMES', 6)) VISGRIDCOLS = int(globals().get('VISGRIDCOLS', 3)) VISCELLH = int(globals().get('VISCELLH', 420)) VISMAXSIDE = int(globals().get('VISMAXSIDE', 2000)) VISJPEGQUALITY = int(globals().get('VISJPEGQUALITY', 85))

VISSHOWCONF = bool(globals().get('VISSHOWCONF', True)) VISSHOWFOOT = bool(globals().get('VISSHOWFOOT', True))

VISDIR = os.path.join(OUTPUTDIR, 'debugframes', 'detections') os.makedirs(VISDIR, exist_ok=True)

Классы, если они вдруг не сохранились в globals

CLSBALL = int(globals().get('CLSBALL', 0)) CLSGK = int(globals().get('CLSGK', 1)) CLSPLAYER = int(globals().get('CLSPLAYER', 2)) CLSREF = int(globals().get('CLSREF', 3))

=====================================================

3. Выбор кадров для проверки

=====================================================

Берём равномерно по всему сегменту, а не только начало.

if len(TRACKINGFRAMEIDS) <= VISNUMFRAMES: selectedframeids = list(TRACKINGFRAMEIDS) else: idxs = np.linspace( 0, len(TRACKINGFRAMEIDS) - 1, VISNUMFRAMES ).astype(int)

selectedframeids = [TRACKINGFRAMEIDS[i] for i in idxs]

selectedframeids = sorted(set(selectedframeids))

print("🖼️ Визуальная проверка детекций") print(f" Кадров для показа: {len(selectedframeids)}") print(f" frameids: {selectedframe_ids}")

=====================================================

4. Вспомогательные функции

=====================================================

def visputtext(img, text, org, scale=0.55, color=(255, 255, 255), thickness=2): """ Текст с чёрной обводкой, чтобы был читаем на любом фоне. """ cv2.putText( img, text, org, cv2.FONTHERSHEYSIMPLEX, scale, (0, 0, 0), thickness + 2, cv2.LINE_AA )

cv2.putText( img, text, org, cv2.FONTHERSHEYSIMPLEX, scale, color, thickness, cv2.LINE_AA )

def getclasscolor(classid): """ Цвета классов в BGR. """ classid = int(class_id)

if classid == CLSBALL: return (255, 0, 0) # синий elif classid == CLSGK: return (0, 0, 255) # красный elif classid == CLSPLAYER: return (0, 255, 0) # зелёный elif classid == CLSREF: return (0, 255, 255) # жёлтый else: return (255, 255, 255)

def resizefordisplay(img, maxside=VISMAXSIDE): """ Уменьшает изображение для показа в Colab, чтобы не выводить слишком тяжёлые картинки. """ h, w = img.shape[:2] longside = max(h, w)

if longside <= maxside: return img

scale = maxside / float(longside)

neww = max(1, int(w * scale)) newh = max(1, int(h * scale))

return cv2.resize( img, (neww, newh), interpolation=cv2.INTER_AREA )

def makecontactsheet(images, cols=VISGRIDCOLS, cellh=VISCELL_H): """ Собирает несколько кадров в один контактный лист. """ if not images: return None

resized = []

for img in images: h, w = img.shape[:2]

if h <= 0 or w <= 0: continue

scale = cellh / float(h) neww = max(1, int(w * scale))

resizedimg = cv2.resize( img, (neww, cellh), interpolation=cv2.INTERAREA )

resized.append(resized_img)

if not resized: return None

rows = []

for i in range(0, len(resized), cols): rowimages = resized[i:i + cols] row = np.hstack(rowimages) rows.append(row)

max_w = max(row.shape[1] for row in rows)

padded_rows = []

for row in rows: rowh, roww = row.shape[:2]

if roww < maxw: pad = np.zeros( (rowh, maxw - row_w, 3), dtype=np.uint8 ) row = np.hstack([row, pad])

padded_rows.append(row)

contactsheet = np.vstack(paddedrows)

return contact_sheet

=====================================================

5. Отрисовка детекций на кадре

=====================================================

def drawdetectionsonframe(frame, detections, frameid): """ Рисует:

  • bbox;
  • class name;
  • confidence;
  • foot-точку для игроков/вратаря/судьи;
  • центр для мяча;
  • служебную статистику по кадру. """ vis = frame.copy()

for det in detections: x1, y1, x2, y2 = det['bbox'] x1 = int(round(x1)) y1 = int(round(y1)) x2 = int(round(x2)) y2 = int(round(y2))

classid = int(det['classid']) conf = float(det['conf']) color = getclasscolor(class_id)

# bbox cv2.rectangle( vis, (x1, y1), (x2, y2), color, 2 )

# label label = str(det.get('classname', classid))

if VISSHOWCONF: label += f" {conf:.2f}"

visputtext( vis, label, (x1, max(0, y1 - 8)), scale=0.45, color=(255, 255, 255), thickness=1 )

# ball center if classid == CLSBALL: cx = int(round(det['cx'])) cy = int(round(det['cy']))

cv2.circle( vis, (cx, cy), 3, (255, 0, 0), -1, cv2.LINE_AA )

# foot point for persons elif VISSHOWFOOT: fx = int(round(det['footx'])) fy = int(round(det['footy']))

cv2.circle( vis, (fx, fy), 3, color, -1, cv2.LINE_AA )

# Служебная статистика по кадру total = len(detections)

playersgk = sum( 1 for d in detections if int(d['classid']) in (CLSPLAYER, CLSGK) )

ballcount = sum( 1 for d in detections if int(d['classid']) == CLS_BALL )

refcount = sum( 1 for d in detections if int(d['classid']) == CLS_REF )

header = ( f"frame={frameid} | " f"players+gk={playersgk} | " f"ball={ballcount} | " f"ref={refcount} | " f"total={total}" )

visputtext( vis, header, (10, 30), scale=0.7, color=(255, 255, 255), thickness=2 )

return vis

=====================================================

6. Чтение нужных кадров одним проходом

=====================================================

neededframeids = set(selectedframeids) framesbyid = {}

print() print("🎞️ Чтение кадров для визуализации...")

for frameid, frame in itervideoframes(): if frameid in neededframeids: framesbyid[frameid] = frame neededframeids.remove(frameid)

if len(neededframeids) == 0: break

if len(framesbyid) == 0: raise RuntimeError("❌ Не удалось прочитать кадры для визуализации.")

missingids = sorted(set(selectedframeids) - set(framesby_id.keys()))

if len(missingids) > 0: print(f"⚠️ Не удалось прочитать кадры: {missingids}")

=====================================================

7. Отрисовка, сохранение и показ

=====================================================

visimages = [] savedpaths = []

print() print("🎨 Отрисовка детекций...")

for frameid in selectedframeids: if frameid not in framesbyid: continue

frame = framesbyid[frameid] detections = getdetectionsforframe(frame_id)

visframe = drawdetectionsonframe( frame=frame, detections=detections, frameid=frameid )

outpath = os.path.join( VISDIR, f"detections{int(frameid):06d}.jpg" )

cv2.imwrite( outpath, visframe, [int(cv2.IMWRITEJPEGQUALITY), VISJPEGQUALITY] )

visimages.append(visframe) savedpaths.append(outpath)

print(f"✅ Сохранено debug-изображений: {len(savedpaths)}") print(f" Папка: {VISDIR}")

=====================================================

8. Контактный лист

=====================================================

if len(visimages) > 0: contactsheet = makecontactsheet( visimages, cols=VISGRIDCOLS, cellh=VISCELLH )

if contactsheet is not None: contactsheet = resizefordisplay( contactsheet, maxside=VISMAXSIDE )

ret, encoded = cv2.imencode( '.jpg', contactsheet, [int(cv2.IMWRITEJPEG_QUALITY), 80] )

if ret: print() print("🖼️ Контактный лист:") display(Image(data=encoded.tobytes()))

contactpath = os.path.join( VISDIR, "detectionscontactsheet.jpg" )

cv2.imwrite( contactpath, contactsheet, [int(cv2.IMWRITEJPEGQUALITY), VISJPEGQUALITY] )

print(f"✅ Контактный лист сохранён: {contact_path}")

=====================================================

9. Очистка памяти

=====================================================

if callable(globals().get('freememory')): freememory()

print() print("✅ Ячейка 8 готова: детекции визуально проверены")

@title. Пересоздание baseline

Пересоздавать baseline нужно, если изменилось что-то из входных условий, влияющих на треки.

Обязательно пересоздать baseline

1. Изменился сегмент видео

2. Изменилась модель детекции

3. Изменился набор отслеживаемых классов

4. Изменился BoT-SORT конфиг

5. Изменилась версия Ultralytics или трекера

6. Baseline-кэш повреждён или не совпадает с текущим экспериментом

Baseline можно оставить, если вы меняете только последующие модули:

гомографию;

проекцию;

миникап;

гибридный трекер;

Re-ID;

мяч;

визуализацию;

метрики гибрида;

экспорт.

То есть baseline нужен как контрольная точка. Если detector и segment не менялись, его можно переиспользовать.

Чтобы пересоздать baseline:

USEBASELINECACHE = False

@title Патч для Ячейки 9: используем встроенный botsort.yaml от Ultralytics

import ultralytics import os

1. Находим абсолютный путь к дефолтному botsort.yaml внутри пакета ultralytics

BOTSORTYAML = os.path.join(os.path.dirname(ultralytics.file), 'cfg', 'trackers', 'botsort.yaml') print(f"✅ Используем встроенный конфиг: {BOTSORTYAML}")

2. Переопределяем переменную в globals, чтобы Ячейка 9 её подхватила

(и не пыталась создать свой неполный custom_botsort.yaml)

globals()['BOTSORTYAML'] = BOTSORTYAML

3. Очищаем старые артефакты baseline, чтобы Ячейка 9 пересоздала их с нуля

for f in ['/content/output/baselinetracks.json', '/content/cache/baselinesegment.mp4']: if os.path.exists(f): os.remove(f) print(f"🗑️ Удалён: {f}")

print("\n👉 Теперь просто запустите Ячейку 9 заново.")

@title 9. Baseline BoT-SORT трекинг на том же сегменте

import os import json import time from collections import Counter

import cv2 import numpy as np import torch

=====================================================

1. Проверка зависимостей

=====================================================

requiredvars = [ 'model', 'VIDEOPATH', 'CACHEDIR', 'OUTPUTDIR', 'SEGMENTSTARTFRAME', 'NUMDEBUGFRAMES', 'FRAMESTRIDE', 'itervideo_frames' ]

for varname in requiredvars: assert varname in globals(), f"❌ Не найдена переменная/функция {varname}. Сначала выполните предыдущие ячейки."

=====================================================

2. Параметры baseline-трекинга

=====================================================

BOTSORTYAML = str(globals().get('BOTSORTYAML', '/content/custom_botsort.yaml'))

BASELINESEGMENTVIDEO = os.path.join(CACHEDIR, 'baselinesegment.mp4') BASELINETRACKSPATH = str( globals().get( 'BASELINETRACKSPATH', os.path.join(OUTPUTDIR, 'baselinetracks.json') ) )

USEBASELINECACHE = bool(globals().get('USEBASELINECACHE', True))

Классы, которые отслеживаем в baseline.

По умолчанию только игроки и вратари, без судей и мяча.

CLSBALL = int(globals().get('CLSBALL', 0)) CLSGK = int(globals().get('CLSGK', 1)) CLSPLAYER = int(globals().get('CLSPLAYER', 2)) CLSREF = int(globals().get('CLSREF', 3))

BASELINETRACKCLASSES = tuple( int(c) for c in globals().get('PLAYERCLASSES', (CLSPLAYER, CLS_GK)) )

BASELINECONF = float(globals().get('CONF', CONF)) BASELINEIOU = float(globals().get('IOU', IOU)) BASELINEMAXDET = int(globals().get('DETECTIONMAXDET', 40)) BASELINEIMGSZ = int(globals().get('DETECTIONIMGSZ', 640))

BASELINEFPS = float(globals().get('VIDEOFPS', 25.0)) INFERENCEDEVICE = 0 if torch.cuda.isavailable() else 'cpu'

os.makedirs(CACHEDIR, existok=True) os.makedirs(OUTPUTDIR, existok=True)

=====================================================

3. Имена классов

=====================================================

if 'CLASSNAMES' in globals(): BASELINECLASSNAMES = dict(CLASSNAMES) elif hasattr(model, 'names'): BASELINECLASSNAMES = { int(k): str(v) for k, v in model.names.items() } else: BASELINECLASSNAMES = { CLSBALL: 'ball', CLSGK: 'goalkeeper', CLSPLAYER: 'player', CLSREF: 'referee' }

=====================================================

4. BoT-SORT yaml на случай, если его нет

=====================================================

if not os.path.exists(BOTSORTYAML): print("⚠️ custombotsort.yaml не найден. Создаём базовый конфиг.")

with open(BOTSORTYAML, 'w', encoding='utf-8') as f: f.write( "trackertype: botsort\n" "trackhighthresh: 0.4\n" "tracklowthresh: 0.2\n" "newtrackthresh: 0.3\n" "trackbuffer: 60\n" "matchthresh: 0.8\n" "gmc_method: sparseOptFlow\n" )

print("🧭 Baseline BoT-SORT") print(f" tracker yaml: {BOTSORTYAML}") print(f" segment video: {BASELINESEGMENTVIDEO}") print(f" tracks json: {BASELINETRACKSPATH}") print(f" track classes: {BASELINETRACK_CLASSES}")

=====================================================

5. Статистика по трекам

=====================================================

def printbaselinestats(tracks, meta=None): if not tracks: print("⚠️ Baseline-треки пусты.") return

frameids = sorted(set(int(t['frameid']) for t in tracks)) trackids = sorted(set(int(t['trackid']) for t in tracks))

recordsperframe = Counter(int(t['frame_id']) for t in tracks)

avgtracksperframe = len(tracks) / max(1, len(frameids))

classcounter = Counter(int(t['classid']) for t in tracks)

print() print("📊 Baseline stats:") print(f" Записей треков: {len(tracks)}") print(f" Кадров с треками: {len(frameids)}") print(f" Уникальных trackid: {len(trackids)}") print(f" Среднее число треков на кадр: {avgtracksperframe:.2f}")

if meta is not None and 'frameids' in meta: print(f" Кадров в сегменте: {len(meta['frameids'])}")

print(" Распределение по классам:") for clsid, cnt in classcounter.items(): clsname = BASELINECLASSNAMES.get(int(clsid), str(clsid)) print(f" {clsname}: {cnt}")

=====================================================

6. Попытка использовать существующий baseline-кэш

=====================================================

baseline_ready = False

if USEBASELINECACHE and os.path.exists(BASELINETRACKSPATH): try: print() print("📦 Найден baseline_tracks.json. Пробуем использовать кэш...")

with open(BASELINETRACKSPATH, 'r', encoding='utf-8') as f: payload = json.load(f)

BASELINEMETA = payload.get('meta', {}) BASELINETRACKS = payload.get('tracks', [])

cachesegmentstart = int(BASELINEMETA.get('segmentstartframe', -1)) cachestride = int(BASELINEMETA.get('framestride', -1)) cachenumframes = int(BASELINEMETA.get('numframes', -1))

cachevalid = ( cachesegmentstart == int(SEGMENTSTARTFRAME) and cachestride == int(FRAMESTRIDE) and cachenum_frames >= 0 )

if cachevalid: BASELINEFRAMEIDS = [ int(x) for x in BASELINEMETA.get('frame_ids', []) ]

if len(BASELINEFRAMEIDS) == 0: BASELINEFRAMEIDS = sorted( set(int(t['frameid']) for t in BASELINETRACKS) )

baseline_ready = True

print("✅ Baseline-кэш корректен.") printbaselinestats(BASELINETRACKS, BASELINEMETA)

else: print("⚠️ Baseline-кэш не соответствует текущему сегменту. Будет пересоздан.")

except Exception as e: print(f"⚠️ Не удалось прочитать baseline-кэш: {e}") print("Будет запущен новый baseline-трекинг.") baseline_ready = False

=====================================================

7. Если кэш не готов — создаём baseline заново

=====================================================

if not baselineready: # ===================================================== # 7.1. Создаём временный видео-сегмент # ===================================================== # Это нужно, чтобы BoT-SORT работал ровно на том же отрезке, # что и гибридный трекер, включая FRAMESTRIDE. print() print("🎬 Создание временного видео-сегмента для baseline...")

writer = None BASELINEFRAMEIDS = []

for frameid, frame in itervideo_frames(): if writer is None: h, w = frame.shape[:2]

fourcc = cv2.VideoWriterfourcc(*'mp4v') writer = cv2.VideoWriter( BASELINESEGMENTVIDEO, fourcc, BASELINEFPS, (w, h) )

if not writer.isOpened(): raise RuntimeError( f"❌ Не удалось создать видео-файл: {BASELINESEGMENTVIDEO}" )

writer.write(frame) BASELINEFRAMEIDS.append(int(frame_id))

if len(BASELINEFRAMEIDS) >= NUMDEBUGFRAMES: break

if writer is not None: writer.release()

if len(BASELINEFRAMEIDS) == 0: raise RuntimeError("❌ Не удалось записать кадры для baseline-сегмента.")

print(f"✅ Сегмент создан: {len(BASELINEFRAMEIDS)} кадров") print(f" Файл: {BASELINESEGMENTVIDEO}")

# ===================================================== # 7.2. Запуск BoT-SORT через model.track # ===================================================== print() print("🚀 Запуск BoT-SORT baseline...")

trackkwargs = dict( source=BASELINESEGMENTVIDEO, tracker=BOTSORTYAML, stream=True, device=INFERENCEDEVICE, conf=BASELINECONF, iou=BASELINEIOU, maxdet=BASELINEMAXDET, imgsz=BASELINE_IMGSZ, verbose=False )

if len(BASELINETRACKCLASSES) > 0: trackkwargs['classes'] = list(BASELINETRACK_CLASSES)

# persist может не поддерживаться в некоторых версиях Ultralytics try: results = model.track(persist=True, track_kwargs) except TypeError as e: print(f"⚠️ persist=True не поддерживается ({e}). Пробуем без persist.") results = model.track(track_kwargs)

BASELINE_TRACKS = []

localframeidx = 0 t0 = time.perf_counter()

for result in results: if localframeidx >= len(BASELINEFRAMEIDS): break

globalframeid = int(BASELINEFRAMEIDS[localframeidx])

boxes = result.boxes

if boxes is not None and boxes.id is not None: xyxy = boxes.xyxy.detach().cpu().numpy().astype(np.float32) trackids = boxes.id.detach().cpu().numpy().astype(np.int32) classids = boxes.cls.detach().cpu().numpy().astype(np.int8) confs = boxes.conf.detach().cpu().numpy().astype(np.float32)

n = len(track_ids)

for i in range(n): x1 = float(xyxy[i][0]) y1 = float(xyxy[i][1]) x2 = float(xyxy[i][2]) y2 = float(xyxy[i][3])

trackid = int(trackids[i]) classid = int(classids[i]) conf = float(confs[i])

footx = (x1 + x2) * 0.5 footy = y2

BASELINETRACKS.append( { 'frameid': globalframeid, 'trackid': trackid, 'classid': classid, 'classname': BASELINECLASSNAMES.get(classid, str(classid)), 'bbox': [x1, y1, x2, y2], 'conf': conf, 'footimage': [footx, footy] } )

localframeidx += 1

if localframeidx % 50 == 0: elapsed = time.perfcounter() - t0 fps = localframeidx / elapsed if elapsed > 0 else 0.0 print(f" Обработано кадров: {localframe_idx} | {fps:.1f} кадров/сек")

if localframeidx >= NUMDEBUGFRAMES: break

elapsed = time.perf_counter() - t0

if localframeidx < len(BASELINEFRAMEIDS): print( f"⚠️ BoT-SORT обработал {localframeidx} кадров из " f"{len(BASELINEFRAMEIDS)}. Возможно, видео-сегмент закончился раньше." )

# ===================================================== # 7.3. Сохраняем baseline в JSON # ===================================================== BASELINEMETA = { 'createdunixtime': time.time(), 'videopath': VIDEOPATH, 'segmentstartframe': int(SEGMENTSTARTFRAME), 'numframes': len(BASELINEFRAMEIDS), 'framestride': int(FRAMESTRIDE), 'trackclasses': list(BASELINETRACKCLASSES), 'trackeryaml': BOTSORTYAML, 'baselineconf': BASELINECONF, 'baselineiou': BASELINEIOU, 'baselinemaxdet': BASELINEMAXDET, 'baselineimgsz': BASELINEIMGSZ, 'baselinefpssetting': BASELINEFPS, 'processedframes': localframeidx, 'totaltrackrecords': len(BASELINETRACKS), 'elapsedseconds': elapsed, 'frameids': BASELINEFRAMEIDS }

payload = { 'meta': BASELINEMETA, 'tracks': BASELINETRACKS }

with open(BASELINETRACKSPATH, 'w', encoding='utf-8') as f: json.dump(payload, f, ensure_ascii=False, separators=(',', ':'))

print() print("✅ Baseline-трекинг завершён.") print(f" Обработано кадров: {localframeidx}") print(f" Записей треков: {len(BASELINETRACKS)}") print(f" Время работы: {elapsed:.2f} сек") print(f" Скорость: {localframeidx / elapsed:.1f} кадров/сек" if elapsed > 0 else "") print(f" Сохранено: {BASELINETRACKS_PATH}")

printbaselinestats(BASELINETRACKS, BASELINEMETA)

=====================================================

8. Финальная проверка

=====================================================

assert 'BASELINETRACKS' in globals(), "❌ BASELINETRACKS не создан." assert 'BASELINEMETA' in globals(), "❌ BASELINEMETA не создан." assert 'BASELINEFRAMEIDS' in globals(), "❌ BASELINEFRAMEIDS не создан."

if callable(globals().get('freememory')): freememory()

print() print("✅ Ячейка 9 готова: baseline BoT-SORT треки сохранены в BASELINE_TRACKS") print(" Следующий шаг — baseline-метрики.")

@title 10. Baseline-метрики и приведение к единому формату

import json import numpy as np from collections import defaultdict, Counter

=====================================================

1. Проверка зависимостей

=====================================================

assert 'BASELINETRACKS' in globals(), "❌ BASELINETRACKS не найден. Выполните ячейку 9." assert 'BASELINEMETA' in globals(), "❌ BASELINEMETA не найден." assert 'OUTPUTDIR' in globals(), "❌ OUTPUTDIR не найден."

BASELINEMETRICSPATH = globals().get( 'BASELINEMETRICSPATH', f"{OUTPUTDIR}/baselinemetrics.json" )

=====================================================

2. Группировка треков по trackid и по frameid

=====================================================

tracksbyid = defaultdict(list) tracksbyframe = defaultdict(list)

for record in BASELINETRACKS: tid = record['trackid'] fid = record['frameid'] tracksbyid[tid].append(record) tracksby_frame[fid].append(record)

=====================================================

3. Расчёт длин треков и их доминирующих классов

=====================================================

tracklengths = [] trackclasses = {}

for tid, records in tracksbyid.items(): # Сортируем по frameid (на случай возможных пропусков или нелинейностей) records.sort(key=lambda x: x['frameid']) length = len(records) track_lengths.append(length)

# Определяем доминирующий класс для трека (так как класс мог прыгать) classcounts = Counter(r['classid'] for r in records) dominantclass = classcounts.mostcommon(1)[0][0] trackclasses[tid] = dominant_class

tracklengths = np.array(tracklengths)

=====================================================

4. Расчёт статистики по кадрам

=====================================================

frameswithtracks = sorted(tracksbyframe.keys()) tracksperframe = [len(tracksbyframe[fid]) for fid in frameswithtracks] tracksperframe = np.array(tracksperframe)

=====================================================

5. Формирование словаря метрик

=====================================================

metrics = { 'totalframes': len(frameswithtracks), 'totaluniquetracks': len(tracksbyid), 'totalrecords': len(BASELINE_TRACKS),

'tracksperframe': { 'mean': float(np.mean(tracksperframe)), 'median': float(np.median(tracksperframe)), 'min': int(np.min(tracksperframe)), 'max': int(np.max(tracksperframe)), 'std': float(np.std(tracksperframe)) },

'tracklengths': { 'mean': float(np.mean(tracklengths)), 'median': float(np.median(tracklengths)), 'min': int(np.min(tracklengths)), 'max': int(np.max(tracklengths)), 'std': float(np.std(tracklengths)) },

# Фрагментация треков (чем больше коротких треков, тем хуже трекинг / больше ID switches) 'trackdurationbins': { 'veryshort (< 5 frames)': int(np.sum(tracklengths < 5)), 'short (5-14 frames)': int(np.sum((tracklengths >= 5) & (tracklengths < 15))), 'medium (15-49 frames)': int(np.sum((tracklengths >= 15) & (tracklengths < 50))), 'long (50-149 frames)': int(np.sum((tracklengths >= 50) & (tracklengths < 150))), 'verylong (>= 150 frames)': int(np.sum(tracklengths >= 150)) },

'classdistribution': {int(k): int(v) for k, v in Counter(trackclasses.values()).items()} }

=====================================================

6. Сохранение метрик в JSON

=====================================================

with open(BASELINEMETRICSPATH, 'w', encoding='utf-8') as f: json.dump(metrics, f, indent=2, ensure_ascii=False)

=====================================================

7. Вывод результатов в консоль

=====================================================

print("📊 Baseline-метрики (внутренние, без Ground Truth):") print(f" Всего кадров с треками: {metrics['totalframes']}") print(f" Всего уникальных trackid: {metrics['totaluniquetracks']}") print() print(" 📈 Треков в кадре (players + goalkeepers):") print(f" Среднее: {metrics['tracksperframe']['mean']:.2f}") print(f" Медиана: {metrics['tracksperframe']['median']:.1f}") print(f" Min/Max: {metrics['tracksperframe']['min']} / {metrics['tracksperframe']['max']}") print() print(" 📏 Длина треков (в кадрах):") print(f" Среднее: {metrics['tracklengths']['mean']:.1f}") print(f" Медиана: {metrics['tracklengths']['median']:.1f}") print(f" Min/Max: {metrics['tracklengths']['min']} / {metrics['tracklengths']['max']}") print() print(" 📦 Распределение длин треков (фрагментация):") for binname, count in metrics['trackdurationbins'].items(): print(f" {binname}: {count}") print() print(" 🎭 Распределение доминирующих классов по трекам:") if 'CLASSNAMES' in globals(): for clsid, count in metrics['classdistribution'].items(): print(f" {CLASSNAMES.get(int(clsid), clsid)}: {count}") else: for clsid, count in metrics['classdistribution'].items(): print(f" class {cls_id}: {count}")

print() print(f"✅ Метрики сохранены: {BASELINEMETRICSPATH}") print("✅ Ячейка 10 готова. Следующий шаг — калибровка поля (гомография).")

@title 11 v15. Конфигурация поля + модель ключевых точек + параметры калибровки v31

v31 = v25 + ФИКС ЛЕВО-ПРАВО:

[+] CHIR_*: параметры теста хиральности — детерминированный выбор лево-право

вместо шума RANSAC. Конвенция: миникапа = вид сверху со стороны камеры

(ближняя бровка внизу [FLIP_Y в ячейке 13] И право кадра в ближней зоне

= +x макета);

[+] финальная нормализация по каждому run (страховка при склейках со сменой

стороны камеры) — в ячейке 12.

Архитектура v25 не тронута. ВАЖНО: после этой ячейки ВСЕГДА перезапускайте ячейку 12.

import os import cv2 import numpy as np from ultralytics import YOLO

CALIBPARAMSVERSION = 'v31'

=====================================================

1. Метрическая сетка поля (метры, 105x68), 32 вершины

=====================================================

PITCH_METERS = np.array([ [0, 0], [0, 13.84], [0, 24.84], [0, 43.16], [0, 54.16], [0, 68], # 0-5 левая линия ворот [5.5, 24.84], [5.5, 43.16], [11, 34], # 6-8 левая вратарская + точка [16.5, 13.84], [16.5, 24.84], [16.5, 43.16], [16.5, 54.16], # 9-12 левая штрафная [52.5, 0], [52.5, 24.85], [52.5, 43.15], [52.5, 68], # 13-16 центральная линия/круг [88.5, 13.84], [88.5, 24.84], [88.5, 43.16], [88.5, 54.16], # 17-20 правая штрафная [94, 34], [99.5, 24.84], [99.5, 43.16], # 21-23 правая точка + вратарская [105, 0], [105, 13.84], [105, 24.84], [105, 43.16], [105, 54.16], [105, 68], # 24-29 правая линия ворот [43.35, 34], [61.65, 34]], dtype=np.float32) # 30-31 круг лево/право

=====================================================

2. Топология линий (1-based пары вершин)

=====================================================

PITCH_EDGES = [ (1, 2), (2, 3), (3, 4), (4, 5), (5, 6), (25, 26), (26, 27), (27, 28), (28, 29), (29, 30), (1, 14), (14, 25), (6, 17), (17, 30), (14, 17), (2, 10), (10, 11), (11, 12), (12, 13), (13, 5), (3, 7), (7, 8), (8, 4), (26, 18), (18, 19), (19, 20), (20, 21), (21, 29), (27, 23), (23, 24), (24, 28), ]

class PitchConfig: """Лёгкая замена sports.configs.soccer.SoccerPitchConfiguration.""" def _init_(self, vertices, edges): self.vertices = [tuple(map(float, v)) for v in vertices] self.edges = edges

PITCHCONFIG = PitchConfig(PITCHMETERS, PITCHEDGES) PITCHVERTICESM = PITCHMETERS print(f"✅ Конфигурация поля: {len(PITCHVERTICESM)} вершин (метры), {len(PITCH_EDGES)} рёбер")

=====================================================

3. Загрузка YOLO-pose модели ключевых точек поля

=====================================================

MODELPATH = "/content/pitchdetectiongorsky001.pt" assert os.path.exists(MODELPATH), f"❌ Модель не найдена: {MODELPATH}" FIELDMODEL = YOLO(MODELPATH) print(f"✅ Модель загружена. Классы: {FIELD_MODEL.names}")

=====================================================

4. Параметры калибровки v31

=====================================================

HOMOGRAPHYDIR = globals().get('HOMOGRAPHYDIR', os.path.join(OUTPUTDIR, 'homography')) os.makedirs(HOMOGRAPHYDIR, exist_ok=True)

--- вход keypoints ---

AUTOIMGSZPROBE = bool(globals().get('AUTOIMGSZPROBE', True)) KEYPOINTIMGSZ = int(globals().get('KEYPOINTIMGSZ', 640)) # нативный размер модели KPINFERCONF = float(globals().get('KPINFERCONF', 0.35))

--- субпиксельное уточнение ---

SUBPIXENABLE = bool(globals().get('SUBPIXENABLE', True)) SUBPIXWIN = int(globals().get('SUBPIXWIN', 9)) SUBPIXMINPIX = int(globals().get('SUBPIXMINPIX', 6)) SUBPIXMAXSHIFTPX = float(globals().get('SUBPIXMAXSHIFTPX', 3.5)) SUBPIXMAXTHICK = float(globals().get('SUBPIXMAXTHICK', 6.0)) SUBPIXMINSPAN = float(globals().get('SUBPIXMINSPAN', 10.0)) SUBPIXTHRMIN = float(globals().get('SUBPIXTHRMIN', 140.0)) SUBPIXTHROFFSET = float(globals().get('SUBPIXTHROFFSET', 60.0))

--- фит H (RANSAC + Tukey-IRLS с геовесами) ---

MINPOINTSFORH = int(globals().get('MINPOINTSFORH', 6)) RANSACT = float(globals().get('RANSACT', 2.0)) MAXERR = float(globals().get('MAXERR', 2.0)) TUKEYC = float(globals().get('TUKEYC', 1.2)) GEOWEIGHT = bool(globals().get('GEOWEIGHT', True)) BIAST = float(globals().get('BIAST', 1.0)) XSPANMIN, YSPANMIN, HULL_MIN = 20.0, 15.0, 120.0

--- QC фитов (v18) ---

FININLIERT = float(globals().get('FININLIERT', 1.5)) FINMINFRAC = float(globals().get('FINMINFRAC', 0.5)) FINMEDT = float(globals().get('FINMEDT', 2.5)) MEDWIN, EMAALPHA = int(globals().get('MEDWIN', 7)), float(globals().get('EMAALPHA', 0.4)) MAXQCITER = int(globals().get('MAXQCITER', 4))

--- sanity якорей ---

SANMINPOSVERTS = int(globals().get('SANMINPOSVERTS', 10)) SANXYFACTOR = float(globals().get('SANXYFACTOR', 12.0)) SANAREAMINFRAC = float(globals().get('SANAREAMINFRAC', 0.005)) SANAREAMAXFRAC = float(globals().get('SANAREAMAXFRAC', 200.0))

--- маска видимости вершин в P-пространстве ---

VISMAXFACTOR = float(globals().get('VISMAXFACTOR', 8.0))

--- transfer-гомография ---

GRANSACPX = float(globals().get('GRANSACPX', 3.0)) GMAXRESIDM = float(globals().get('GMAXRESIDM', 1.5))

--- детекция склеек (только разрывы серий сглаживания) ---

CUTGAPFRAMES = int(globals().get('CUTGAPFRAMES', 30)) CUTCENTERFRAC = float(globals().get('CUTCENTERFRAC', 0.30)) CUTLOGAREA = float(globals().get('CUTLOGAREA', 0.5))

--- ICP point-to-segment (консервативный, v18-стиль порогов) ---

ICPENABLE = bool(globals().get('ICPENABLE', True)) ICPMINPIX = int(globals().get('ICPMINPIX', 150)) WHITEPIXCAP = int(globals().get('WHITEPIXCAP', 2000)) ICPGATEM = tuple(globals().get('ICPGATEM', (4.0, 2.5, 1.5))) ICPSPANXM = float(globals().get('ICPSPANXM', 8.0)) ICPSPANYM = float(globals().get('ICPSPANYM', 5.0)) ICPHUBERM = float(globals().get('ICPHUBERM', 0.4)) ICPMEDMAXM = float(globals().get('ICPMEDMAXM', 2.0)) ICPFRACT = float(globals().get('ICPFRACT', 0.35)) ICPFRACMIN = float(globals().get('ICPFRACMIN', 0.40)) ICPTRYFINT = float(globals().get('ICPTRYFINT', 0.8)) ICPFINGUARDMAX = float(globals().get('ICPFINGUARDMAX', 1.5)) ICPSTRONGMED = float(globals().get('ICPSTRONGMED', 0.35)) ICPSTRONGFRAC = float(globals().get('ICPSTRONGFRAC', 0.50))

--- НОВОЕ (v31): хиральность — детерминированный выбор лево-право ---

CHIRENABLE = bool(globals().get('CHIRENABLE', True)) # мастер-выключатель CHIRMINVOTES = int(globals().get('CHIRMINVOTES', 3)) # мин. валидных проб для решения CHIRXPAIR = tuple(globals().get('CHIRXPAIR', (0.25, 0.75))) # доли ширины: левая/правая точки CHIRYFRACS = tuple(globals().get('CHIRYFRACS', (0.70, 0.85, 0.95))) # высоты проб (ближняя зона) CHIRMINXSHARE = float(globals().get('CHIRMINXSHARE', 0.5)) # гейт вырожденности (вид вдоль поля)

--- P-сглаживание (v18-стиль: median + SavGol, NaN-aware) ---

SMMEDWIN = int(globals().get('SMMEDWIN', 31)) SMSGWIN = int(globals().get('SMSGWIN', 51)) SMSGPOLY = int(globals().get('SMSGPOLY', 2)) SMGAPMAX = int(globals().get('SMGAPMAX', 25)) # макс. внутренняя дыра интерполяции SMEDGEGAP = int(globals().get('SMEDGEGAP', 10)) # макс. краевая экстраполяция HINTERPGAP = int(globals().get('HINTERPGAP', 60)) # макс. дыра интерполяции H

--- симметрия ---

SYMMARGIN = float(globals().get('SYMMARGIN', 1.05))

print(f"✅ Ячейка 11 v15 готова (CALIBPARAMSVERSION={CALIBPARAMSVERSION}). Переходите к ячейке 12 v31.")

@title 12 v31. Автокалибровка v25 + ФИКС ЛЕВО-ПРАВО (хиральность).

#

Дефект: шаблон поля зеркально-симметричен по x -> пары кандидатов (s0/s1, s2/s3)

дают МАТЕМАТИЧЕСКИ РАВНЫЕ ошибки фита (зеркалирование — изометрия шаблона),

и выбор лево-право решал шум RANSAC. Проверка yt<yb фиксирует только верх-низ.

#

Фикс (автоматический):

[4b] ХИРАЛЬНОСТЬ при выборе симметрии: конвенция «миникапа = вид сверху со

стороны камеры» — право кадра в ближней зоне (низ кадра, где ближняя

бровка после yt<yb) = +x макета. Голосование по фита́м выборки;

зеркальный мажоритарный результат -> переключение на партнёра

si <-> s{i^1} (БЕСПЛАТНО: ошибки равны). Вырожденные ракурсы

(вид вдоль поля) отбрасываются гейтом CHIRMINX_SHARE;

[11b] финальная нормализация по каждому run (страховка для склеек со сменой

стороны камеры): зеркальные run получают X_MIRROR @ H (x'=105-x).

fin-ошибки НЕ меняются (изометрия): fin_stats для перевёрнутых кадров

использует зеркальный маппинг (SYM[1]-перестановка шаблона).

Остальное — v25 без изменений: sign_norm, маски видимости, sanity, QC, transfer,

ICP, median(31)+SavGol(51), пересборка H, страховки.

import os, cv2, json, bisect import numpy as np from scipy.spatial import cKDTree from scipy.ndimage import medianfilter from scipy.signal import savgolfilter from tqdm.notebook import tqdm import matplotlib.pyplot as plt

for v in ['FIELDMODEL', 'PITCHVERTICESM', 'PITCHCONFIG', 'TRACKINGFRAMEIDS', 'itervideoframes', 'HOMOGRAPHYDIR', 'VIDEOPATH']: assert v in globals(), f"❌ Не найдено: {v}. Проверьте ячейку 11." assert callable(globals().get('readframeatindex')), "❌ Нет readframeatindex (ячейка 5)." assert str(globals().get('CALIBPARAMSVERSION')) == 'v31', \ "❌ Параметры от СТАРОЙ ячейки 11! ПЕРЕЗАПУСТИТЕ ячейку 11 v15, затем 12 v31."

VERTM = PITCHVERTICESM.astype(np.float64) verts32 = VERTM.astype(np.float32) NVERT = len(VERTM)

=====================================================

0. Параметры (локальные значения — источник истины; globals только для тюнинга)

=====================================================

AUTOIMGSZPROBE = bool(globals().get('AUTOIMGSZPROBE', True)) KPIMGSZ = int(globals().get('KEYPOINTIMGSZ', 640)) KPINFERCONF = float(globals().get('KPINFERCONF', 0.35)) SUBPIXENABLE = bool(globals().get('SUBPIXENABLE', True)) SUBPIXWIN = 9 SUBPIXMINPIX = 6 SUBPIXMAXSHIFTPX = 3.5 SUBPIXMAXTHICK = 6.0 SUBPIXMINSPAN = 10.0 SUBPIXTHRMIN = 140.0 SUBPIXTHROFFSET = 60.0

MINPOINTSFORH = 6 RANSACT = 2.0 MAXERR = 2.0 TUKEYC = 1.2 GEOWEIGHT = True BIAST = 1.0 XSPANMIN, YSPANMIN, HULL_MIN = 20.0, 15.0, 120.0

FININLIERT, FINMINFRAC, FINMEDT = 1.5, 0.5, 2.5 MEDWIN, EMAALPHA, MAXQCITER = 7, 0.4, 4

SANMINPOSVERTS = 10 SANXYFACTOR = 12.0 SANAREAMINFRAC = 0.005 SANAREAMAXFRAC = 200.0 VISMAX_FACTOR = 8.0

GRANSACPX, GMAXRESIDM = 3.0, 1.5 CUTGAPFRAMES, CUTCENTERFRAC, CUTLOG_AREA = 30, 0.30, 0.5

ICPENABLE = bool(globals().get('ICPENABLE', True)) ICPMINPIX = 150 WHITEPIXCAP = 2000 ICPGATEM = (4.0, 2.5, 1.5) ICPSPANXM = 8.0 ICPSPANYM = 5.0 ICPHUBERM = 0.4 ICPMEDMAXM = 2.0 ICPFRACT = 0.35 ICPFRACMIN = 0.40 ICPTRYFINT = 0.8 ICPFINGUARDMAX = 1.5 ICPSTRONGMED = 0.35 ICPSTRONG_FRAC = 0.50

--- хиральность (v31) ---

CHIRENABLE = bool(globals().get('CHIRENABLE', True)) CHIRMINVOTES = int(globals().get('CHIRMINVOTES', 3)) CHIRXPAIR = tuple(globals().get('CHIRXPAIR', (0.25, 0.75))) CHIRYFRACS = tuple(globals().get('CHIRYFRACS', (0.70, 0.85, 0.95))) CHIRMINXSHARE = float(globals().get('CHIRMINXSHARE', 0.5))

SMMEDWIN, SMSGWIN, SMSGPOLY = 31, 51, 2 SMGAPMAX, SMEDGEGAP, HINTERPGAP = 25, 10, 60 SYM_MARGIN = 1.05

--- служебное для хиральности ---

XMIRROR = np.array([[-1.0, 0.0, 105.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], dtype=np.float64) # x' = 105 - x XFLIPFRAMES = set() # кадры, чьи H зеркалированы пост-фактум (run-нормализация) XFLIPRUNS = [] # [start, end] перевёрнутых run'ов MAPXMIR = None # зеркальный маппинг keypoints (заполняется после выбора симметрии)

print(f"⚙️ v31: imgsz={KPIMGSZ} (probe={'on' if AUTOIMGSZPROBE else 'off'}), " f"subpix={'on' if SUBPIXENABLE else 'off'}, ICP={'on' if ICPENABLE else 'off'}, " f"хиральность={'on' if CHIRENABLE else 'off'} " f"(пробы x{CHIRXPAIR} на y{CHIRYFRACS}, гейт {CHIRMINXSHARE}), " f"sanity: minpos={SANMINPOSVERTS}, xy={SANXYFACTOR:.0f}x, vis={VISMAX_FACTOR:.0f}x")

SUBPIX_STATS = {'try': 0, 'ref': 0}

=====================================================

1. А/Б-выбор imgsz по качеству keypoints

=====================================================

def probeimgszquality(): probefids = [TRACKINGFRAMEIDS[i] for i in np.linspace(0, len(TRACKINGFRAMEIDS) - 1, 6).astype(int)] frames = [] for f in probefids: fr = readframeatindex(VIDEOPATH, int(f)) if fr is not None: frames.append(fr) if not frames: return int(KPIMGSZ), {} scores = {} for sz in (640, 960, 1280): cnts, confs = [], [] for fr in frames: r = FIELDMODEL(fr, imgsz=sz, conf=KPINFER_CONF, verbose=False)[0] if r.keypoints is None or len(r.keypoints.xy) == 0 or r.keypoints.conf is None: cnts.append(0); confs.append(0.0); continue kcf = r.keypoints.conf[0].detach().cpu().numpy() m = kcf >= 0.5 cnts.append(int(m.sum())) confs.append(float(kcf[m].mean()) if m.any() else 0.0) scores[sz] = float(np.mean(cnts)) + 0.5 float(np.mean(confs)) print(f" imgsz={sz}: kps(conf>=0.5) в среднем {np.mean(cnts):.1f}, score={scores[sz]:.2f}") best = max(scores, key=scores.get) if scores[best] <= scores[640] 1.05: best = 640 return int(best), scores

if AUTOIMGSZPROBE: print("🔬 А/Б-тест разрешения инференса keypoints...") KPIMGSZ, probescores = probeimgszquality() else: probescores = {} print(f"🔬 Выбран imgsz={KPIMGSZ}, conf={KPINFERCONF}, subpix={'on' if SUBPIXENABLE else 'off'}")

=====================================================

2. ЗНАКОВАЯ НОРМАЛИЗАЦИЯ + утилиты проекции и фита

=====================================================

def signnorm(H, imgpts): """H и -H — одна гомография. Нормализуем знак так, чтобы median(W)>0 для видимых image-точек => у видимых вершин обратная проекция даёт w>0.""" H = np.asarray(H, np.float64) W = H[2, 0] img_pts[:, 0] + H[2, 1] img_pts[:, 1] + H[2, 2] m = float(np.median(W)) if m < 0: return -H return H

def proj(H, pts): pts = np.ascontiguousarray(np.asarray(pts, np.float64).reshape(-1, 1, 2)) return cv2.perspectiveTransform(pts, np.asarray(H, np.float64)).reshape(-1, 2)

def resid(H, src, dst): return np.linalg.norm(proj(H, src) - np.asarray(dst, np.float64), axis=1)

def tukeyw(r, c): r = np.asarray(r, np.float64) w = np.zeroslike(r) m = np.abs(r) < c if m.any(): u = r[m] / c w[m] = (1.0 - u u) * 2 return w

def wdlst(src, dst, w): src = np.asarray(src, np.float64); dst = np.asarray(dst, np.float64) n = len(src) x, y, X, Y = src[:, 0], src[:, 1], dst[:, 0], dst[:, 1] A = np.zeros((2 n, 9)) A[0::2, :3] = -np.stack([x, y, np.ones(n)], 1) A[0::2, 6:] = np.stack([X x, X y, X], 1) A[1::2, 3:6] = -np.stack([x, y, np.ones(n)], 1) A[1::2, 6:] = np.stack([Y x, Y y, Y], 1) M = A.T np.repeat(np.asarray(w, np.float64), 2) @ A _, V = np.linalg.eigh(M) H = V[:, 0].reshape(3, 3) return H / H[2, 2]

def pxtom_scale(H, src): p0 = proj(H, src) p1 = proj(H, np.asarray(src, np.float64) + np.array([1.0, 0.0])) p2 = proj(H, np.asarray(src, np.float64) + np.array([0.0, 1.0])) return 0.5 * (np.linalg.norm(p1 - p0, axis=1) + np.linalg.norm(p2 - p0, axis=1))

def spreadok(dst): if cv2.contourArea(np.asarray(dst, np.float32).reshape(-1, 1, 2)) < HULLMIN: return False if (dst[:, 0].max() - dst[:, 0].min()) < XSPANMIN: return False if (dst[:, 1].max() - dst[:, 1].min()) < YSPANMIN: return False return True

def fitH(src, dst, cf, w, h): src = np.asarray(src, np.float64); dst = np.asarray(dst, np.float64) if not spreadok(dst): return None, None, None H, inl = cv2.findHomography(src.astype(np.float32), dst.astype(np.float32), cv2.RANSAC, RANSACT) if H is None or inl is None or inl.sum() < MINPOINTSFORH: return None, None, None H = H.astype(np.float64) m = inl.ravel() == 1 for it in range(4): r = resid(H, src[m], dst[m]) ct = max(TUKEYC, 2.0 float(np.median(r))) if it < 2 else TUKEY_C w_ = tukey_w(r, c_t) np.asarray(cf, np.float64)[m] if GEOWEIGHT: w = w / (0.5 + pxtomscale(H, src[m])) if float(w.sum()) < 1e-6: w = np.asarray(cf, np.float64)[m].copy() H = wdlst(src[m], dst[m], w) if H is None or not np.isfinite(H).all(): return None, None, None H = signnorm(H, src[m]) # знаковая нормализация (фикс v25) r = resid(H, src[m], dst[m]) err = float(r.mean()) if err > MAXERR: return None, None, None Hi = np.linalg.inv(H) quad = proj(Hi, VERTM[[0, 5, 29, 24]]) if cv2.contourArea(quad.astype(np.float32)) < 0.05 w h: return None, None, None yt = float(np.mean(proj(Hi, VERTM[[5, 16, 29]])[:, 1])) yb = float(np.mean(proj(Hi, VERTM[[0, 13, 24]])[:, 1])) if not (yt < yb): return None, None, None return H, err, (r, m)

=====================================================

3. Keypoints: инференс + субпиксель

=====================================================

def subpixelrefine(frame, kxy, kcf): fh, fw = frame.shape[:2] gray = cv2.GaussianBlur(cv2.cvtColor(frame, cv2.COLORBGR2GRAY), (3, 3), 0) sat = cv2.cvtColor(frame, cv2.COLORBGR2HSV)[:, :, 1] kxy2 = kxy.copy() for i in range(len(kxy)): if kcf[i] < KPINFERCONF: continue x, y = float(kxy[i][0]), float(kxy[i][1]) if not (SUBPIXWIN < x < fw - SUBPIXWIN - 1 and SUBPIXWIN < y < fh - SUBPIXWIN - 1): continue xi, yi = int(round(x)), int(round(y)) x0, x1 = xi - SUBPIXWIN, xi + SUBPIXWIN + 1 y0, y1 = yi - SUBPIXWIN, yi + SUBPIXWIN + 1 g = gray[y0:y1, x0:x1]; s = sat[y0:y1, x0:x1] thr = max(SUBPIXTHRMIN, float(g.max()) - SUBPIXTHROFFSET) m = (g >= thr) & (s <= 110) SUBPIXSTATS['try'] += 1 if int(m.sum()) < SUBPIXMINPIX: continue ys, xs = np.nonzero(m) pts = np.stack([xs, ys], 1).astype(np.float64) c = pts.mean(0); d = pts - c cov = (d.T @ d) / len(pts) , evec = np.linalg.eigh(cov) vmain = evec[:, 1] along = d @ vmain; across = d @ evec[:, 0] if along.max() - along.min() < SUBPIXMINSPAN: continue if np.abs(across).max() > SUBPIXMAXTHICK: continue p = np.array([x - x0, y - y0]) t = (p - c) @ vmain q = c + t * vmain if float(np.hypot(p[0] - q[0], p[1] - q[1])) > SUBPIXMAXSHIFTPX: continue kxy2[i] = [q[0] + x0, q[1] + y0] SUBPIX_STATS['ref'] += 1 return kxy2

def getkps(frame): r = FIELDMODEL(frame, imgsz=KPIMGSZ, conf=KPINFERCONF, verbose=False)[0] if r.keypoints is None or len(r.keypoints.xy) == 0: return None, None kxy = r.keypoints.xy[0].detach().cpu().numpy().astype(np.float64) kcf = (r.keypoints.conf[0].detach().cpu().numpy().astype(np.float32) if r.keypoints.conf is not None else np.ones(len(kxy), np.float32)) if SUBPIXENABLE: kxy = subpixel_refine(frame, kxy, kcf) return kxy, kcf

def pairs(kxy, kcf, mapping, w, h, thr, skip=()): src, dst, idx, cf = [], [], [], [] for i in range(len(kxy)): if i not in mapping or i in skip or kcf[i] < thr: continue x, y = float(kxy[i][0]), float(kxy[i][1]) if not (0 < x < w) or not (0 < y < h): continue src.append([x, y]); dst.append(VERT_M[mapping[i]]) idx.append(i); cf.append(float(kcf[i])) return (np.array(src, np.float64), np.array(dst, np.float64), np.array(idx, int), np.array(cf, np.float64))

=====================================================

4. Симметрии (v18) + НОВОЕ: хиральность

=====================================================

YAMLBASE = { 0: 29, 1: 28, 2: 27, 3: 3, 4: 4, 5: 5, 6: 23, 7: 7, 8: 8, 9: 20, 10: 19, 11: 11, 12: 12, 13: 13, 14: 14, 15: 15, 16: 16, 17: 17, 18: 18, 19: 10, 20: 9, 21: 21, 22: 22, 23: 6, 24: 24, 25: 25, 26: 26, 27: 2, 28: 1, 29: 0, 30: 30, 31: 31 } W, HM = 105.0, 68.0 TRANS = [lambda p: p, lambda p: np.stack([W - p[:, 0], p[:, 1]], 1), lambda p: np.stack([p[:, 0], HM - p[:, 1]], 1), lambda p: np.stack([W - p[:, 0], HM - p[:, 1]], 1)] SYM = [] for t in TRANS: tp = t(VERTM) d = np.linalg.norm(tp[:, None, :] - VERTM[None, :, :], axis=2) SYM.append({j: int(np.argmin(d[j])) for j in range(32)}) CAND = {} for si, g in enumerate(SYM): CAND[f'yamls{si}'] = {i: g[c] for i, c in YAML_BASE.items()}

cv2.setRNGSeed(42); np.random.seed(42)

def scoremapping(mapping, frames): s = 0.0 for kxy, kcf, w, h in frames: src, dst, idx, cf = pairs(kxy, kcf, mapping, w, h, 0.5) if len(src) < MINPOINTSFORH: continue H, err, = fitH(src, dst, cf, w, h) if H is None: continue s += len(src) - 0.5 * err return s

print("🔍 Выбор симметрии (каждый 25-й кадр)...") samples = [] for fid, frame in itervideoframes(): if fid % 25 != 0: continue kxy, kcf = get_kps(frame) if kxy is not None and (kcf >= 0.5).sum() >= 8: samples.append((kxy, kcf, frame.shape[1], frame.shape[0])) print(f"🔍 Образцов: {len(samples)}")

scores = {name: scoremapping(m, samples) for name, m in CAND.items()} order = sorted(scores, key=scores.get, reverse=True) bestname = order[0] print(f" Топ-2: {[(n, round(scores[n], 1)) for n in order[:2]]}") if len(order) > 1 and scores[order[0]] < SYMMARGIN * scores[order[1]]: print("⚖️ Разрыв <5%: tie-breaker по всем кадрам...") tb = {} for name in order[:2]: s = 0.0 for fid, frame in itervideoframes(): kxy, kcf = getkps(frame) if kxy is None: continue s += scoremapping(CAND[name], [(kxy, kcf, frame.shape[1], frame.shape[0])]) tb[name] = s print(f" {name}: {s:.1f}") bestname = max(tb, key=tb.get) bestmap = CAND[bestname] print(f"✅ Симметрия (по скору): {best_name}")

=====================================================

4b. НОВОЕ (v31): ХИРАЛЬНОСТЬ — детерминированный выбор лево-право

=====================================================

def hchirality(H, fw, fh): """Знак соответствия «право кадра (ближняя зона)» -> метрический x: +1 = камерно-консистентно, -1 = зеркально, None = вырожденный ракурс (вид вдоль поля: |dx| < CHIRMINXSHARE |d|). Требует sign_norm(H) и yt<yb (ближняя бровка y=0 — внизу кадра).""" H = np.asarray(H, np.float64) votes = [] xa, xb = CHIR_X_PAIR for yy in CHIR_Y_FRACS: pa = H @ np.array([xa fw, yy fh, 1.0], dtype=np.float64) pb = H @ np.array([xb fw, yy fh, 1.0], dtype=np.float64) if abs(pa[2]) < 1e-9 or abs(pb[2]) < 1e-9: continue ma = pa[:2] / pa[2] mb = pb[:2] / pb[2] dx = mb[0] - ma[0] dy = mb[1] - ma[1] nrm = float(np.hypot(dx, dy)) if nrm < 1e-6 or abs(dx) < CHIR_MIN_X_SHARE nrm: continue votes.append(1 if dx > 0 else -1) if not votes: return None s = sum(votes) if s > 0: return 1 if s < 0: return -1 return None

CHIRSEL = {'enabled': bool(CHIRENABLE), 'votesconsistent': 0, 'votesmirrored': 0, 'degenerate': 0, 'switched': False} if CHIRENABLE: votes = [] deg = 0 for kxy, kcf, w, h in samples: src, dst, idx, cf = pairs(kxy, kcf, bestmap, w, h, 0.5) if len(src) < MINPOINTSFORH: continue H, err, = fitH(src, dst, cf, w, h) if H is None: continue c = hchirality(H, w, h) if c is None: deg += 1 continue votes.append(c) CHIRSEL['votesconsistent'] = int(sum(1 for v in votes if v > 0)) CHIRSEL['votesmirrored'] = int(sum(1 for v in votes if v < 0)) CHIRSEL['degenerate'] = int(deg) if len(votes) >= CHIRMINVOTES and CHIRSEL['votesmirrored'] > CHIRSEL['votesconsistent']: i = int(bestname.split('s')[-1]) alt = f'yamls{i ^ 1}' print(f"🌗 Хиральность: зеркальных {CHIRSEL['votesmirrored']} > консистентных " f"{CHIRSEL['votesconsistent']} (вырожденных {deg}) " f"-> ПЕРЕКЛЮЧЕНИЕ {bestname} -> {alt} (бесплатно: ошибки зеркальных кандидатов равны)") bestname = alt bestmap = CAND[bestname] CHIRSEL['switched'] = True elif len(votes) >= CHIRMINVOTES: print(f"🌗 Хиральность: консистентных {CHIRSEL['votesconsistent']}, " f"зеркальных {CHIRSEL['votesmirrored']} (вырожденных {deg}) " f"-> {bestname} без переключения") else: print(f"🌗 Хиральность: мало голосов ({len(votes)} < {CHIRMINVOTES}, " f"вырожденных {deg}) -> {bestname} по скору (run-нормализация ниже — вторая сеть)") else: print("⏭️ Хиральность отключена (CHIR_ENABLE=False)")

зеркальный маппинг keypoints (для fin_stats перевёрнутых run'ов):

x-зеркалирование шаблона = перестановка SYM[1]

MAPXMIR = {i: SYM[1][bestmap[i]] for i in best_map}

def evalmapfor(fid): """Маппинг keypoints для оценки fin: обычный или зеркальный (для кадров, чьи H были x-зеркалированы run-нормализацией). Остатки идентичны (изометрия).""" if fid in XFLIPFRAMES: return MAPXMIR return best_map

print(f"✅ Симметрия (итог): {best_name}")

=====================================================

5. Pass 1 -> bad_idx -> Pass 2

=====================================================

kpscache = {} print("🔄 Pass 1 (кэш keypoints)...") for fid, frame in tqdm(itervideoframes(), total=len(TRACKINGFRAMEIDS), desc="Pass1"): kxy, kcf = getkps(frame) if kxy is None: continue kps_cache[fid] = (kxy, kcf, frame.shape[1], frame.shape[0])

def framedims(f): if f in kpscache: return float(kpscache[f][2]), float(kpscache[f][3]) return 1280.0, 720.0

if SUBPIXSTATS['try']: print(f"🎯 Субпиксель: уточнено {SUBPIXSTATS['ref']}/{SUBPIXSTATS['try']} keypoints " f"({100.0 * SUBPIXSTATS['ref'] / max(1, SUBPIX_STATS['try']):.0f}%)")

def collectidxresid(mapping): d = {} for fid, (kxy, kcf, w, h) in kpscache.items(): src, dst, idx, cf = pairs(kxy, kcf, mapping, w, h, 0.5) if len(src) < 8: src, dst, idx, cf = pairs(kxy, kcf, mapping, w, h, 0.3) if len(src) < MINPOINTSFORH: continue H, err, info = fitH(src, dst, cf, w, h) if H is None or info is None: continue r, m = info for i, v in zip(idx[m], r): d.setdefault(int(i_), []).append(float(v)) return d

def fitpasscache(mapping, skip=()): Hs, errs = {}, [] for fid, (kxy, kcf, w, h) in kpscache.items(): src, dst, idx, cf = pairs(kxy, kcf, mapping, w, h, 0.5, skip=skip) if len(src) < 8: src, dst, idx, cf = pairs(kxy, kcf, mapping, w, h, 0.3, skip=skip) if len(src) < MINPOINTSFORH: continue H, err, = fitH(src, dst, cf, w, h) if H is None: continue Hs[fid] = H errs.append(err) return Hs, errs

idxresid = collectidxresid(bestmap) badidx = {i for i, rs in idxresid.items() if len(rs) >= 5 and np.median(rs) > BIAST} print(f"🚫 Исключены смещённые индексы: {sorted(badidx)}")

Hraw2, errs = fitpasscache(bestmap, tuple(badidx)) nframesall = len(TRACKINGFRAMEIDS) if errs: print(f"📊 Pass2: валидных {len(Hraw2)}/{nframesall} " f"({100 * len(Hraw2) / max(1, nframesall):.0f}%), err inliers raw: {np.mean(errs):.2f} м") else: print(f"📊 Pass2: валидных {len(Hraw2)}/{nframesall}") assert len(H_raw2) > 0, "❌ Не получено ни одной валидной гомографии."

def finstats(fid, H): """(медиана residual против keypoints, доля inliers). Маппинг — с учётом возможного x-зеркалирования кадра run-нормализацией (изометрия -> те же значения).""" if H is None or fid not in kpscache: return None kxy, kcf, w, h = kpscache[fid] src, dst, idx, cf = pairs(kxy, kcf, evalmapfor(fid), w, h, 0.5, skip=badidx) if len(src) < MINPOINTSFORH: return None r = resid(H, src, dst) return float(np.median(r)), float(np.mean(r < FININLIER_T))

=====================================================

6. Видимость вершин + sanity

=====================================================

def vertpixmasked(H, fw, fh): """Проекции 32 вершин; видима: w>0 и |coord|<=VISMAXFACTORкадр. Требует sign_norm(H) — иначе знак w случаен.""" try: Hi = np.linalg.inv(np.asarray(H, np.float64)) except np.linalg.LinAlgError: return np.full((N_VERT, 2), np.nan) Vh = np.hstack([VERT_M, np.ones((N_VERT, 1))]) Pw = Vh @ Hi.T pix = np.full((N_VERT, 2), np.nan) fin = np.all(np.isfinite(Pw), axis=1) pos = fin & (Pw[:, 2] > 1e-6) if pos.any(): pp = Pw[pos, :2] / Pw[pos, None, 2] keep = (np.abs(pp[:, 0]) <= VIS_MAX_FACTOR fw) & (np.abs(pp[:, 1]) <= VISMAXFACTOR * fh) idxs = np.where(pos)[0][keep] pix[idxs] = pp[keep] return pix

def nvisible(H, fw, fh): return int(np.isfinite(vertpix_masked(H, fw, fh)[:, 0]).sum())

def framepixsanity(H, fw, fh): try: Hi = np.linalg.inv(H) except np.linalg.LinAlgError: return False pix = vertpixmasked(H, fw, fh) nv = int(np.isfinite(pix[:, 0]).sum()) if nv < SANMINPOSVERTS: return False pp = pix[np.isfinite(pix[:, 0])] if float(np.abs(pp[:, 0]).max()) > SANXYFACTOR * fw: return False if float(np.abs(pp[:, 1]).max()) > SANXYFACTOR * fh: return False ci = [0, 5, 29, 24] if np.all(np.isfinite(pix[ci, 0])): area = float(cv2.contourArea(pix[ci].astype(np.float32))) if area < SANAREAMINFRAC fw fh or area > SANAREAMAX_FRAC fw fh: return False return True

nv = [] for f, H in Hraw2.items(): nv.append(nvisible(H, *framedims(f))) nv = np.array(nv) print(f"🔎 Видимых вершин на фит (после нормализации знака): " f"p10={np.percentile(nv, 10):.0f}, p50={np.percentile(nv, 50):.0f}, " f"p90={np.percentile(nv, 90):.0f}, min={nv.min():.0f} " f"(порог sanity: {SANMINPOSVERTS})")

sanitydrop = sorted(f for f, H in Hraw2.items() if not framepixsanity(H, *framedims(f))) print(f"🧯 Sanity: отброшено {len(sanitydrop)} из {len(H_raw2)}"

  • (f" (кадры: {sanitydrop[:40]}{'...' if len(sanitydrop) > 40 else ''})" if sanitydrop else "")) Hraw2 = {f: H for f, H in Hraw2.items() if f not in set(sanitydrop)} assert len(H_raw2) > 0, "❌ Все фиты отброшены sanity."

=====================================================

7. QC до сходимости (v18)

=====================================================

sortedfids = sorted(TRACKINGFRAMEIDS) fids = sortedfids nfr = len(fids) posof = {f: i for i, f in enumerate(fids)}

def zerophaseema(seq, alpha): fwd, p = [], seq[0] for x in seq: p = alpha x + (1 - alpha) p fwd.append(p) bwd, p = [], seq[-1] for x in reversed(seq): p = alpha x + (1 - alpha) p bwd.append(p) bwd.reverse() return 0.5 * (np.array(fwd) + np.array(bwd))

def smoothsequence(Hdict): validfids = sorted(Hdict.keys()) HBF = {} for fid in sortedfids: if fid in Hdict: HBF[fid] = Hdict[fid]; continue pf = max([f for f in validfids if f < fid], default=None) nf = min([f for f in validfids if f > fid], default=None) if pf is not None and nf is not None: a = (fid - pf) / (nf - pf) HBF[fid] = Hdict[pf] (1 - a) + H_dict[nf] a elif pf is not None: HBF[fid] = Hdict[pf] elif nf is not None: HBF[fid] = Hdict[nf] half = MEDWIN // 2 Hm = {} for i, fid in enumerate(sortedfids): win = [HBF[sortedfids[j]] for j in range(max(0, i - half), min(len(sortedfids), i + half + 1))] Hm[fid] = np.median(np.stack(win), axis=0) Hseq = np.stack([Hm[f] for f in sortedfids]) Hs = np.emptylike(Hseq) for e in range(9): Hs[:, e // 3, e % 3] = zerophaseema(Hseq[:, e // 3, e % 3], EMAALPHA) return {fid: Hs[i] for i, fid in enumerate(sortedfids)}

Hvalid = dict(Hraw2) for it in range(MAXQCITER): Hstmp = smoothsequence(Hvalid) keep = {} for fid, H in Hvalid.items(): fs = finstats(fid, Hstmp.get(fid)) if fs is None or (fs[1] >= FINMINFRAC and fs[0] <= FINMEDT): keep[fid] = H print(f"🧹 QC итерация {it + 1}: отброшено {len(Hvalid) - len(keep)}") if len(keep) == len(Hvalid): break H_valid = keep

SRCH = {fid: 'own' for fid in Hvalid} print(f"🧹 QC: осталось якорей {len(H_valid)}")

=====================================================

8. Склейки (только для разрыва серий сглаживания)

=====================================================

def fitsignature(H, fw, fh): pix = vertpix_masked(H, fw, fh) m = np.isfinite(pix[:, 0]) if int(m.sum()) < 6: return None c = np.median(pix[m], axis=0) hull = cv2.convexHull(pix[m].astype(np.float32)) area = max(float(cv2.contourArea(hull)), 1.0) return (float(c[0]), float(c[1])), float(np.log(area))

anchsorted = sorted(Hvalid.keys()) sig = {} for f in anchsorted: sig[f] = fitsignature(Hvalid[f], *framedims(f)) hmed = float(np.median([framedims(f)[1] for f in anchsorted])) CUTCENTERPX = max(120.0, CUTCENTERFRAC * hmed) cuts = set() for a, b in zip(anchsorted, anchsorted[1:]): if b - a > CUTGAPFRAMES: cuts.add(b); continue sa, sb = sig[a], sig[b] if sa is None or sb is None: continue if float(np.hypot(sb[0][0] - sa[0][0], sb[0][1] - sa[0][1])) > CUTCENTERPX: cuts.add(b); continue if abs(sb[1] - sa[1]) > CUTLOGAREA: cuts.add(b) print(f"🎬 Склеек (разрывы сглаживания): {len(cuts)}"

  • (f" | границы: {sorted(cuts)}" if cuts else ""))

runs кадров между склейками

runs = [] cur = [fids[0]] for f in fids[1:]: if f in cuts: runs.append(cur); cur = [f] else: cur.append(f) runs.append(cur)

=====================================================

9. Transfer внутри runs

=====================================================

def transferH(t, a): if t not in kpscache or a not in kpscache or a not in Hvalid: return None kxyt, kcft, w, h = kpscache[t] kxya, kcfa, , = kpscache[a] shared = [i for i in range(len(kxyt)) if kcft[i] >= 0.5 and kcfa[i] >= 0.5 and 0 < kxyt[i][0] < w and 0 < kxyt[i][1] < h and 0 < kxya[i][0] < w and 0 < kxya[i][1] < h] if len(shared) < 4: return None src = kxyt[shared].astype(np.float64) dst = kxya[shared].astype(np.float64) G, inl = cv2.findHomography(src.astype(np.float32), dst.astype(np.float32), cv2.RANSAC, GRANSACPX) if G is None or inl is None or int(inl.sum()) < 4: return None sc = float(np.sqrt(abs(np.linalg.det(G[:2, :2])))) if not (0.6 < sc < 1.6): return None Ht = signnorm(Hvalid[a] @ G, src) # нормализация знака по keypoints кадра t if not framepixsanity(Ht, *framedims(t)): return None return Ht

ntransfer = 0 for run in runs: anch = sorted(f for f in run if f in Hvalid) if not anch: continue arr = np.array(anch, dtype=np.int64) for fid in run: if fid in Hvalid: continue j = bisect.bisectleft(arr, fid) pf = int(arr[j - 1]) if j > 0 else None nf = int(arr[j]) if j < len(arr) else None Ht = None for a in (pf, nf): if a is None: continue Ht = transferH(fid, a) if Ht is not None: break if Ht is not None: Hvalid[fid] = Ht SRCH[fid] = 'transfer' ntransfer += 1 print(f"🔗 Transfer: заполнено {n_transfer} кадров")

=====================================================

10. ICP point-to-segment (консервативный)

=====================================================

SEGS = [] for (a, b) in PITCHCONFIG.edges: SEGS.append((VERTM[a - 1].copy(), VERTM[b - 1].copy())) for k in range(32): a0 = 2 * np.pi * k / 32; a1 = 2 * np.pi * (k + 1) / 32 SEGS.append((np.array([52.5 + 9.15 np.cos(a0), 34 + 9.15 np.sin(a0)]), np.array([52.5 + 9.15 np.cos(a1), 34 + 9.15 np.sin(a1)]))) segpts, segid = [], [] for si, (a, b) in enumerate(SEGS): L = float(np.linalg.norm(b - a)) n = max(2, int(L / 0.5)) for t in np.linspace(0.0, 1.0, n): segpts.append(a + (b - a) * t); segid.append(si) SEGPTS = np.asarray(segpts) SEGIDS = np.asarray(segid, int) SEGA = np.asarray([SEGS[i][0] for i in SEGIDS]) SEGB = np.asarray([SEGS[i][1] for i in SEGIDS]) SEGTREE = cKDTree(SEG_PTS)

def projpointssegs(Pp, A, B): AB = B - A denom = np.maximum((AB AB).sum(1), 1e-9) t = np.clip(((Pp - A) AB).sum(1) / denom, 0.0, 1.0) return A + t[:, None] * AB

def segcorr(Pm): d4, i4 = SEGTREE.query(Pm, k=4) if np.ndim(d4) == 1: d4 = d4[:, None]; i4 = i4[:, None] bestd = np.full(len(Pm), np.inf) dst = np.zeros((len(Pm), 2)) for k in range(i4.shape[1]): ids = i4[:, k] q = projpointssegs(Pm, SEGA[ids], SEGB[ids]) dd = np.linalg.norm(Pm - q, axis=1) m = dd < bestd bestd[m] = dd[m]; dst[m] = q[m] return dst, bestd

def whitelinepixels(frame): hsv = cv2.cvtColor(frame, cv2.COLORBGR2HSV) Hh, S, V = cv2.split(hsv) grass = ((Hh > 25) & (Hh < 95) & (S > 40) & (V > 40)).astype(np.uint8) grass = cv2.morphologyEx(grass, cv2.MORPHCLOSE, np.ones((5, 5), np.uint8)) grassd = cv2.dilate(grass, np.ones((7, 7), np.uint8)) > 0 if grassd.sum() < 1000: return np.zeros((0, 2), np.float64) vthr = max(float(np.percentile(V[grassd], 90)), 120.0) sthr = float(np.percentile(S[grassd], 25)) white = (V >= vthr) & (S <= sthr) & grassd ys, xs = np.nonzero(white) if len(xs) > WHITEPIXCAP: ix = np.random.choice(len(xs), WHITEPIX_CAP, replace=False) xs, ys = xs[ix], ys[ix] return np.stack([xs, ys], 1).astype(np.float64)

def icprefine(H0, pix): if pix is None or len(pix) < ICPMINPIX: return None, 'minpix' H = np.asarray(H0, np.float64).copy() for gate in ICPGATEM: pm = proj(H, pix) dst, bestd = segcorr(pm) keep = bestd < gate if int(keep.sum()) < ICPMINPIX: return None, 'gate' Pm = pm[keep]; Dst = dst[keep] src = np.asarray(pix, np.float64)[keep] if (Dst[:, 0].max() - Dst[:, 0].min()) < ICPSPANXM or \ (Dst[:, 1].max() - Dst[:, 1].min()) < ICPSPANYM: return None, 'span' dm = np.linalg.norm(Pm - Dst, axis=1) wts = np.where(dm < ICPHUBERM, 1.0, ICPHUBERM / np.maximum(dm, 1e-9)) Hn = signnorm(wdlst(src, Dst, wts), src) if not np.isfinite(Hn).all() or abs(Hn[2, 2]) < 1e-12: return None, 'degen' H = Hn pm = proj(H, pix) dst, bestd = segcorr(pm) med = float(np.median(bestd)); frac = float(np.mean(bestd < ICPFRACT)) if med > ICPMEDMAXM or frac < ICPFRACMIN: return None, 'med/frac' return (H, (med, frac)), None

nicp, nicprec = 0, 0 ICPCNT = {'skipgood': 0, 'reject': 0, 'ok': 0} if ICPENABLE: print(f"🔄 ICP (доверенные: fin > {ICPTRYFINT} м; недоверенные: всегда)...") for fid, frame in tqdm(itervideoframes(), total=nfr, desc="ICP"): f = int(fid) i = posof.get(f) if i is None: continue H0 = Hvalid.get(f) trusted = H0 is not None if not trusted: run = None for r in runs: if f in r: run = r; break if run is None: continue anch = sorted(a for a in run if a in Hvalid) if not anch: continue arr = np.array(anch, dtype=np.int64) j = bisect.bisectleft(arr, f) pf = int(arr[j - 1]) if j > 0 else None nf = int(arr[j]) if j < len(arr) else None H0 = Hvalid[pf] if (nf is None or (pf is not None and f - pf <= nf - f)) else Hvalid[nf] fin0 = finstats(f, H0) if trusted and fin0 is not None and fin0[0] <= ICPTRYFINT: ICPCNT['skipgood'] += 1 continue res, why = icprefine(H0, whitelinepixels(frame)) if res is None: ICPCNT['reject'] += 1 continue Hr, met = res if trusted: fin1 = finstats(f, Hr) ok = (fin1 is None) or (fin0 is None) or \ (fin1[0] <= max(ICPFINGUARDMAX, fin0[0])) or \ (met[0] <= ICPSTRONGMED and met[1] >= ICPSTRONGFRAC) if not ok: ICPCNT['reject'] += 1 continue Hvalid[f] = Hr if trusted: nicp += 1; SRCH[f] = 'icp' else: nicprec += 1; SRCH[f] = 'icprec' ICPCNT['ok'] += 1 print(f"📐 ICP: уточнено {nicp}, восстановлено {nicprec}; якорей {len(Hvalid)}/{nfr}") print(f" Счётчик ICP: {ICPCNT}") else: print("⏭️ ICP отключён (ICPENABLE=False)")

=====================================================

11. P-пространство (маски видимости) -> median+SavGol -> пересборка H

=====================================================

P = np.full((nfr, NVERT, 2), np.nan) for f, H in Hvalid.items(): P[posof[f]] = vertpixmasked(H, *framedims(f)) print(f"🧭 Якорей: {len(Hvalid)} (own={sum(1 for v in SRCH.values() if v == 'own')}, " f"transfer={ntransfer}, icp={nicp + nicp_rec})")

def fillrun(y): """Заполнение дыр серии y (один run): малые дыры/края — интерп., большие — NaN.""" obs = np.isfinite(y) out = y.copy() valid = obs.copy() if int(obs.sum()) == 0: return out, valid idx = np.where(obs)[0] for a, b in zip(idx[:-1], idx[1:]): L = b - a - 1 if L == 0: continue if L <= SMGAPMAX: out[a + 1:b] = y[a] + (y[b] - y[a]) * (np.arange(a + 1, b) - a) / float(b - a) valid[a + 1:b] = True else: out[a + 1:b] = y[a] + (y[b] - y[a]) * (np.arange(a + 1, b) - a) / float(b - a) valid[a + 1:b] = False # заполнено только для фильтрации if idx[0] > 0: out[:idx[0]] = y[idx[0]] valid[:idx[0]] = idx[0] <= SMEDGEGAP if idx[-1] < len(y) - 1: out[idx[-1] + 1:] = y[idx[-1]] valid[idx[-1] + 1:] = (len(y) - 1 - idx[-1]) <= SMEDGE_GAP return out, valid

def smoothrun(y): n = len(y) obs = np.isfinite(y) if int(obs.sum()) < 5: return y.copy() filled, valid = fillrun(y) medw = min(SMMEDWIN, n if n % 2 == 1 else n - 1) medw = max(3, medw) med = medianfilter(filled, size=medw, mode='nearest') sgw = min(SMSGWIN, n if n % 2 == 1 else n - 1) if sgw >= SMSGPOLY + 2 and sgw % 2 == 1: sm = savgolfilter(med, sgw, SMSGPOLY) else: sm = med out = sm out[~valid] = np.nan return out

Ps = P.copy() for run in runs: if len(run) < 5: continue idxs = [posof[f] for f in run] for c in range(NVERT): for ax in range(2): Ps[idxs, c, ax] = smoothrun(P[idxs, c, ax].copy())

jumplist = [] for ai, bi in zip(range(nfr - 1), range(1, nfr)): d = np.linalg.norm(Ps[bi] - Ps[ai], axis=1) fin = np.isfinite(d) if int(fin.sum()) >= 6: jumplist.append((float(np.mean(d[fin])), fids[ai], fids[bi])) jumplist.sort(key=lambda x: -x[0]) jumpwithin = jumplist[0][0] if jumplist else 0.0 print(f"📉 Скачок вершин (по общим видимым): {jumpwithin:.1f}px") print(" Топ-5 меж-кадровых сдвигов:") for d, fa, fb in jumplist[:5]: print(f" f{fa} -> f{fb}: {d:.1f}px")

Hsmooth = {} for i, f in enumerate(fids): m = np.isfinite(Ps[i, :, 0]) & np.isfinite(Ps[i, :, 1]) if int(m.sum()) >= 6: src32 = np.ascontiguousarray(Ps[i][m].astype(np.float32)) H, = cv2.findHomography(src32, np.ascontiguousarray(verts32[m]), 0) if H is not None and np.isfinite(H).all() and abs(H[2, 2]) > 1e-12: Hsmooth[f] = signnorm(H / H[2, 2], src32.astype(np.float64)) missingH = [f for f in fids if f not in Hsmooth] if missingH and Hsmooth: haveH = np.array(sorted(Hsmooth.keys()), dtype=np.int64) for f in missingH: j = bisect.bisectleft(haveH, f) pf = int(haveH[j - 1]) if j > 0 else None nf = int(haveH[j]) if j < len(haveH) else None if pf is not None and nf is not None and (nf - pf) <= HINTERPGAP: a = (f - pf) / float(nf - pf) H = (1 - a) H_smooth[pf] + a Hsmooth[nf] elif pf is not None: H = Hsmooth[pf].copy() elif nf is not None: H = Hsmooth[nf].copy() else: continue if abs(H[2, 2]) > 1e-12: Hsmooth[f] = H / H[2, 2] print(f"🧩 Пересборка H: напрямую {nfr - len(missingH)}, интерполяцией {len([f for f in missingH if f in Hsmooth])}, " f"без H {len([f for f in missingH if f not in H_smooth])}")

def orientok(H): try: Hi = np.linalg.inv(H) except np.linalg.LinAlgError: return False yt = float(np.mean(proj(Hi, VERTM[[5, 16, 29]])[:, 1])) yb = float(np.mean(proj(Hi, VERT_M[[0, 13, 24]])[:, 1])) return yt < yb

nor = 0 for f in list(Hsmooth.keys()): if orientok(Hsmooth[f]): continue Ha = Hvalid.get(f) if Ha is not None and orientok(Ha): Hsmooth[f] = np.asarray(Ha, np.float64).copy(); nor += 1 if nor: print(f"⚠️ Ориентационная страховка: {nor} кадров")

nguard = 0 for f in fids: Hsf = Hsmooth.get(f) if Hsf is None: continue fss = finstats(f, Hsf) if fss is not None and fss[0] > 3.0: Ha = Hvalid.get(f) if Ha is not None: fsa = finstats(f, Ha) if fsa is not None and fsa[0] < 1.0: Hsmooth[f] = np.asarray(Ha, np.float64).copy() nguard += 1 if nguard: print(f"🛡️ Guard: {nguard} кадров возвращены к якорям")

=====================================================

11b. НОВОЕ (v31): финальная нормализация лево-право по каждому run

(страховка для склеек со сменой стороны камеры).

Зеркальные run: H -> X_MIRROR @ H; fin-ошибки не меняются (изометрия),

finstats автоматически использует зеркальный маппинг (evalmap_for).

=====================================================

if CHIRENABLE: for run in runs: votes = [] for f in run: H = Hsmooth.get(f) if H is None: continue c = hchirality(H, *framedims(f)) if c is not None: votes.append(c) if len(votes) < CHIRMINVOTES: continue npos = sum(1 for v in votes if v > 0) nneg = len(votes) - npos if nneg > npos: for f in run: if f in Hsmooth: Hsmooth[f] = XMIRROR @ Hsmooth[f] XFLIPFRAMES.add(f) XFLIPRUNS.append([int(run[0]), int(run[-1])]) if XFLIPRUNS: print(f"🔄 Run-нормализация лево-право: зеркальных run: {len(XFLIPRUNS)} " f"(диапазоны: {XFLIP_RUNS})") else: print("🔄 Run-нормализация лево-право: все run камерно-консистентны")

=====================================================

12. KPI

=====================================================

HERR = {} for f in fids: H = Hsmooth.get(f) if H is None: continue fs = finstats(f, H) if fs is None: continue HERR[f] = (float(fs[0]), float(fs[1])) HTRUST = set(Hvalid.keys())

medsall = np.array([HERR[f][0] for f in HERR]) if HERR else np.array([]) anchorlike = [f for f in HERR if f in Hvalid] medsanchor = np.array([HERR[f][0] for f in anchorlike]) if anchor_like else np.array([])

print() print("📊 ИТОГ v31:") print(f" Валидных H: {len(Hsmooth)}/{nfr} | склеек: {len(cuts)} | " f"симметрия: {bestname} (хиральность: " f"{'переключена' if CHIRSEL.get('switched') else 'подтверждена/по скору'})") if len(medsall): print(f" Все кадры с измеримой ошибкой (n={len(medsall)}): " f"mean={medsall.mean():.2f} м, p50={np.percentile(medsall, 50):.2f} м, " f"p95={np.percentile(medsall, 95):.2f} м") print(f" 🎯 Доля кадров с ошибкой: <0.25 м: {100 * np.mean(medsall < 0.25):.1f}% | " f"<0.5 м: {100 np.mean(meds_all < 0.5):.1f}% | " f"<1.0 м: {100 np.mean(medsall < 1.0):.1f}%") if len(medsanchor): print(f" Якоря (n={len(medsanchor)}): mean={medsanchor.mean():.2f} м, " f"p50={np.percentile(medsanchor, 50):.2f} м | " f"<0.5 м: {100 * np.mean(medsanchor < 0.5):.1f}%") srccounter = {} for f in Hsmooth: if f in Hvalid: srccounter[SRCH.get(f, 'own')] = srccounter.get(SRCH.get(f, 'own'), 0) + 1 else: srccounter['smooth'] = srccounter.get('smooth', 0) + 1 print(f" Источники: {srccounter}")

def getH(frameid): fid = int(frameid) if fid not in Hsmooth: return None, None, False, 'missing' H = Hsmooth[fid] return H, np.linalg.inv(H), True, 'smoothedv31'

=====================================================

13. Мета + визуализация

=====================================================

with open(os.path.join(HOMOGRAPHYDIR, 'autohomographymeta.json'), 'w') as f: json.dump({'version': 'v31', 'chosen': bestname, 'chirality': CHIRSEL, 'xflipruns': XFLIPRUNS, 'badidx': sorted(int(i) for i in badidx), 'errinliersraw': float(np.mean(errs)) if errs else None, 'kpimgsz': int(KPIMGSZ), 'kpprobe': {str(k): float(v) for k, v in probescores.items()}, 'subpix': dict(SUBPIXSTATS), 'cuts': sorted(int(c) for c in cuts), 'sanitydrop': [int(f) for f in sanitydrop], 'nown': sum(1 for v in SRCH.values() if v == 'own'), 'ntransfer': ntransfer, 'icprefined': nicp, 'icprecovered': nicprec, 'finmedmean': float(medsall.mean()) if len(medsall) else None, 'finmedp95': float(np.percentile(medsall, 95)) if len(medsall) else None, 'sharelt025': float(np.mean(medsall < 0.25)) if len(medsall) else None, 'sharelt05': float(np.mean(medsall < 0.5)) if len(medsall) else None, 'sharelt10': float(np.mean(medsall < 1.0)) if len(medsall) else None, 'topjumps': [[float(d), int(fa), int(fb)] for d, fa, fb in jumplist[:10]], 'jumpwithinpx': jumpwithin, 'sources': srccounter, 'numvalid': len(Hsmooth), 'numframes': len(fids), 'mapping': {str(k): int(v) for k, v in best_map.items()}}, f, indent=2)

visfids = set([TRACKINGFRAMEIDS[i] for i in np.linspace(0, len(TRACKINGFRAMEIDS) - 1, 6).astype(int)]) viscache = {} for fid, frame in itervideoframes(): if fid in visfids: viscache[fid] = frame.copy() fig, axes = plt.subplots(2, 3, figsize=(21, 11)) axes = np.array(axes).ravel() for i, fid in enumerate(sorted(viscache)): vis = viscache[fid] H, Hinv, valid, = getH(fid) if valid: pp = proj(Hinv, VERTM) for i1, i2 in PITCHCONFIG.edges: cv2.line(vis, tuple(pp[i1 - 1].astype(int)), tuple(pp[i2 - 1].astype(int)), (0, 255, 0), 2, cv2.LINEAA) errtxt = f" | err {HERR[fid][0]:.2f}м" if fid in HERR else "" axes[i].imshow(cv2.cvtColor(vis, cv2.COLORBGR2RGB)) axes[i].settitle(f"f{fid}{errtxt}", fontsize=12) axes[i].axis('off') plt.tightlayout() plt.show()

del viscache, kpscache

if callable(globals().get('freememory')): freememory() print("✅ Ячейка 12 v31 готова. getH(frameid); per-frame ошибки — HERR; якоря — HTRUST.") print("👉 Перезапустите 13 → 24 → 25 → 27.")

@title 13 v10. Покадровая гомография из автокалибровки + унификация ориентации

(верх-низ: FLIP_Y; лево-право: нормализовано в ячейке 12 v31)

+ per-frame ошибки и флаг trusted в homographies.json.

Против v9: версия источника v31; проброс chirality/xflipruns в meta.

Запускать после 11 v15 + 12 v31.

import os import json import cv2 import numpy as np import matplotlib.pyplot as plt

for v in ['TRACKINGFRAMEIDS', 'PITCHVERTICESM', 'PITCHCONFIG', 'OUTPUTDIR']: assert v in globals(), f"❌ Не найдено: {v}. Проверьте предыдущие ячейки." assert callable(globals().get('itervideoframes')), "❌ Нет itervideoframes (ячейка 5)."

HOMOGRAPHYDIR = globals().get('HOMOGRAPHYDIR', os.path.join(OUTPUTDIR, 'homography')) os.makedirs(HOMOGRAPHYDIR, existok=True) HOMOGRAPHIESJSON = os.path.join(HOMOGRAPHYDIR, 'homographies.json') PITCHWM = float(np.max(PITCHVERTICES_M[:, 1])) # 68.0

=====================================================

2. Источник H: из памяти (ячейка 12 v31) или с диска

=====================================================

HBYFRAME = {} HERRBYFRAME = {} HTRUSTBYFRAME = {} SRCBYFRAME = {} srcmode = None loadedflipped = False loadedxflip = None

if 'Hsmooth' in globals() and len(Hsmooth) > 0: for fid in TRACKINGFRAMEIDS: if fid in Hsmooth: H = np.asarray(Hsmooth[fid], dtype=np.float64) if abs(H[2, 2]) > 1e-12: H = H / H[2, 2] HBYFRAME[int(fid)] = H SRCBYFRAME[int(fid)] = 'auto' if 'HERR' in globals() and isinstance(HERR, dict): HERRBYFRAME = {int(k): (float(v[0]), float(v[1])) for k, v in HERR.items()} if 'HTRUST' in globals(): HTRUSTBYFRAME = {int(f): True for f in HTRUST} srcmode = 'auto' print(f"✅ H взяты из ячейки 12 (v31): {len(HBYFRAME)}/{len(TRACKINGFRAMEIDS)} кадров, " f"ошибки: {len(HERRBYFRAME)}, якоря: {len(HTRUSTBYFRAME)}") elif os.path.exists(HOMOGRAPHIESJSON): with open(HOMOGRAPHIESJSON, 'r', encoding='utf-8') as f: payload = json.load(f) loadedflipped = bool(payload.get('meta', {}).get('yflip', False)) loadedxflip = payload.get('meta', {}).get('xflipruns', None) want = set(int(x) for x in TRACKINGFRAMEIDS) for fidstr, rec in payload.get('frames', {}).items(): fid = int(fidstr) if fid in want: HBYFRAME[fid] = np.asarray(rec['H'], dtype=np.float64) SRCBYFRAME[fid] = 'loaded' if rec.get('errmedm') is not None: HERRBYFRAME[fid] = (float(rec['errmedm']), float(rec.get('errfrac', 0.0))) if rec.get('trusted'): HTRUSTBYFRAME[fid] = True srcmode = 'loaded' print(f"✅ H загружены из {HOMOGRAPHIESJSON}: " f"{len(HBYFRAME)}/{len(TRACKINGFRAMEIDS)} кадров (yflip: {loadedflipped})") else: raise RuntimeError("❌ Нет Hsmooth (ячейка 12) и нет homographies.json. Сначала ячейка 12 v31.")

have = sorted(HBYFRAME.keys()) missing = [int(fid) for fid in TRACKINGFRAMEIDS if int(fid) not in HBYFRAME] if missing and have: for fid in missing: pf = max([f for f in have if f < fid], default=None) nf = min([f for f in have if f > fid], default=None) if pf is not None and nf is not None: a = (fid - pf) / float(nf - pf) H = (1 - a) H_BY_FRAME[pf] + a HBYFRAME[nf] elif pf is not None: H = HBYFRAME[pf].copy() else: H = HBYFRAME[nf].copy() if abs(H[2, 2]) > 1e-12: H = H / H[2, 2] HBYFRAME[fid] = H SRCBYFRAME[fid] = 'fallback' print(f"⚠️ Fallback (ближайший сосед): {len(missing)} кадров")

assert len(HBYFRAME) > 0, "❌ Не осталось ни одной покадровой H."

=====================================================

3. Унификация ориентации: y' = PITCH_W - y

(верх-низ: ближняя бровка внизу макета; лево-право уже нормализовано

в ячейке 12 — конвенция «миникапа = вид сверху со стороны камеры»)

=====================================================

FLIPY = np.array([[1.0, 0.0, 0.0], [0.0, -1.0, PITCHW_M], [0.0, 0.0, 1.0]], dtype=np.float64)

needflip = (srcmode == 'auto') or (srcmode == 'loaded' and not loadedflipped) if needflip: for fid in HBYFRAME: HBYFRAME[fid] = FLIPY @ HBYFRAME[fid] print(f"🔄 Применён переворот y (y'={PITCHWM:.0f}-y): ближняя бровка внизу макета.") else: print("ℹ️ H уже в унифицированной ориентации (y_flip=True).")

xflipinfo = globals().get('XFLIPRUNS', loadedxflip) if xflipinfo: print(f"🔄 Лево-право: нормализовано в ячейке 12; перевёрнутые run: {xflip_info}") else: print("🔄 Лево-право: все run камерно-консистентны (нормализация ячейки 12).")

=====================================================

4. Утилиты проекции

=====================================================

def getH(frameid): """(H, Hinv, valid, source). H: image -> метры (унифицированная ориентация).""" fid = int(frameid) if fid not in HBYFRAME: return None, None, False, 'none' H = HBYFRAME[fid] try: Hinv = np.linalg.inv(H) except np.linalg.LinAlgError: return H, None, False, SRCBYFRAME[fid] return H, Hinv, True, SRCBYFRAME[fid]

def projecttopitch(frameid, x, y): H, Hinv, valid, src = getH(frameid) if not valid: return None, None, False p = H @ np.array([x, y, 1.0], dtype=np.float64) if abs(p[2]) < 1e-9: return None, None, False return float(p[0] / p[2]), float(p[1] / p[2]), True

def projecttoimage(frameid, xm, ym): H, Hinv, valid, src = getH(frameid) if not valid or Hinv is None: return None, None, False p = Hinv @ np.array([xm, ym, 1.0], dtype=np.float64) if abs(p[2]) < 1e-9: return None, None, False return float(p[0] / p[2]), float(p[1] / p[2]), True

=====================================================

5. Сохранение (+ информация о хиральности)

=====================================================

chir = globals().get('CHIRSEL', None) payload = { 'meta': { 'source': 'autov31', 'chosenmapping': str(globals().get('bestname', 'unknown')), 'numframes': len(HBYFRAME), 'yflip': True, 'calibids': [], 'errstats': {}, 'orientationconvention': 'camera-consistent: near touchline at bottom, image-right = +x', 'chirality': chir, 'xflipruns': xflipinfo }, 'frames': { str(fid): { 'H': H.tolist(), 'source': SRCBYFRAME[fid], 'errmedm': (float(HERRBYFRAME[fid][0]) if fid in HERRBYFRAME else None), 'errfrac': (float(HERRBYFRAME[fid][1]) if fid in HERRBYFRAME else None), 'trusted': bool(fid in HTRUSTBYFRAME) } for fid, H in sorted(HBYFRAME.items()) } } if HERRBYFRAME: mall = np.array([v[0] for v in HERRBYFRAME.values()]) payload['meta']['errstats'] = { 'n': int(len(mall)), 'mean': float(mall.mean()), 'p50': float(np.percentile(mall, 50)), 'p95': float(np.percentile(mall, 95)), 'sharelt025': float(np.mean(mall < 0.25)), 'sharelt05': float(np.mean(mall < 0.5)), 'sharelt10': float(np.mean(mall < 1.0)) } with open(HOMOGRAPHIESJSON, 'w', encoding='utf-8') as f: json.dump(payload, f, separators=(',', ':')) print(f"💾 Сохранено: {HOMOGRAPHIESJSON}")

if HERRBYFRAME: mall = np.array([v[0] for v in HERRBYFRAME.values()]) mtr = np.array([v for k, v in HERRBYFRAME.items() if k in HTRUSTBYFRAME]) \ if HTRUSTBYFRAME else np.array([]) print(f"📏 Качество: все {len(mall)} кадр.: <0.5 м: {100 * np.mean(m_all < 0.5):.1f}%"

  • (f" | якоря ({len(mtr)}): <0.5 м: {100 * np.mean(mtr < 0.5):.1f}%" if len(m_tr) else ""))

=====================================================

6. Визуальная проверка на 6 кадрах

=====================================================

checkids = np.linspace(0, len(TRACKINGFRAMEIDS) - 1, 6).astype(int) checkids = sorted(set(TRACKINGFRAMEIDS[i] for i in check_ids))

needed = set(checkids) framesbyid = {} for fid, frame in itervideoframes(): if fid in needed: framesby_id[fid] = frame needed.discard(fid) if not needed: break

fig, axes = plt.subplots(2, 3, figsize=(21, 11)) axes = axes.ravel() for axi, fid in enumerate(checkids): ax = axes[axi] if fid not in framesbyid: ax.axis('off') continue H, Hinv, valid, src = getH(fid) vis = framesbyid[fid].copy() if valid: pr = cv2.perspectiveTransform( PITCHVERTICESM.reshape(-1, 1, 2).astype(np.float32), Hinv).reshape(-1, 2) for (i, j) in PITCHCONFIG.edges: p1, p2 = pr[i - 1], pr[j - 1] cv2.line(vis, (int(p1[0]), int(p1[1])), (int(p2[0]), int(p2[1])), (0, 255, 0), 2, cv2.LINEAA) errtxt = f" | err {HERRBYFRAME[fid][0]:.2f}м" if fid in HERRBYFRAME else "" trtxt = " | якорь" if fid in HTRUSTBYFRAME else "" ax.imshow(cv2.cvtColor(vis, cv2.COLORBGR2RGB)) ax.settitle(f"frame {fid} | {src}{trtxt}{errtxt}", fontsize=12) ax.axis('off') plt.suptitle("Ячейка 13 v10: покадровая гомография (v31: камерно-консистентное лево-право)") plt.tightlayout() plt.show()

if callable(globals().get('freememory')): freememory()

nauto = sum(1 for s in SRCBYFRAME.values() if s == 'auto') print(f"📊 Источники H: auto={nauto}, " f"fallback={sum(1 for s in SRCBYFRAME.values() if s == 'fallback')}, " f"loaded={sum(1 for s in SRCBYFRAME.values() if s == 'loaded')}") print("✅ Ячейка 13 v10 готова: HBYFRAME, getH(), projecttopitch(), projectto_image()") print("⚠️ Перезапустите downstream: 24 → 25 → 27 (миникапа согласована с видео по лево-право).")

@title 22 v29. Покадровое разделение по цвету формы + автокалибровка

+ нормализация освещения (Ур.1) + 4-кластерные прототипы с РАЗДЕЛЕНИЕМ ШКАЛ (Ур.2-fix).

#

v29 = v28 + исправление регрессии:

[+] Команда назначается по 4-модели (min по подкластерам свет/тень),

а СЕРОСТЬ (порог, ambiv, suspect) — в 2-шкале (dist до среднего

центроида, как в v27). Порог auto/manual считается в 2-шкале ->

возвращаются рабочие значения (manual ~3.62) и ~0 серых полевых;

[+] REFINE отключается при 4-модели (локальный центр смешивал зоны

свет/тень внутри кадра и переклассифицировал неверно);

[+] Guard согласия: слияние 4-кластеров обязано совпадать с 2-кластерной

базой >= 80%, иначе откат к 2-модели;

[+] Диагностика: сколько наблюдений 4-модель перевела в другую команду.

!pip install -q scikit-learn ultralytics transformers accelerate

import os, gc, json, hashlib import numpy as np import cv2 import torch from PIL import Image from collections import defaultdict, Counter from sklearn.cluster import KMeans from sklearn.preprocessing import StandardScaler from sklearn.metrics import silhouettescore, adjustedrand_score from tqdm.notebook import tqdm from ultralytics import YOLO from transformers import AutoModel, AutoProcessor

=====================================================

1. Зависимости

=====================================================

for v in ['VIDEOPATH', 'OUTPUTDIR', 'CACHEDIR', 'DETECTIONSCACHE', 'TRACKINGFRAMEIDS', 'CLSPLAYER', 'CLSGK', 'CLSREF', 'VIDEOFPS', 'SEGMENTSTARTFRAME', 'NUMDEBUGFRAMES', 'FRAMESTRIDE']: assert v in globals(), f"❌ Не найдено: {v}. Выполните ячейки 1-7." assert os.path.exists(DETECTIONSCACHE), "❌ Кэш детекций не найден (ячейка 6)." assert callable(globals().get('getdetectionindices')), "❌ Нет getdetectionindices (ячейка 7)." assert callable(globals().get('itervideoframes')), "❌ Нет itervideoframes (ячейка 5)." assert callable(globals().get('readframeatindex')), "❌ Нет readframeatindex (ячейка 5)."

=====================================================

2. Конфигурация

=====================================================

SPORT = str(globals().get('SPORT', 'football')) DEVICE = str(globals().get('DEVICE', 'cuda'))

--- Уровень 1: нормализация освещения ---

ILLUMNORM = True ILLUMCHROMA = True GRASSPADFRAC = 0.40

--- Уровень 2 (v29): 4-кластерные прототипы, разделение шкал ---

ILLUMSPLIT = True SPLITMINSHARE = 0.04 SPLITMAXTRIM = 0.10 SPLITAGREE_BASE = 0.80 # согласие слияния с 2-кластерной базой, иначе откат

--- v26: автокалибровка ---

AUTOTUNE = True CALIBFRAC = 0.075 CALIBMINFRAMES = 30 CALIBMAXFRAMES = 150 EXCLUDECALIBFRAMES = True AUTO_INFO = {}

PROCESSGKTOO = True PROCESSCLASSES = (CLSPLAYER, CLSGK) if PROCESSGKTOO else (CLSPLAYER,)

UNASSIGNEDTEAMID = 2

OUTLIERK = 3.5 GRAYMEDFACTOR = 1.5 GRAYTHRESHSCALE = 1.6 AMBIVFRAC = 0.0

SMOOTHCOLOR = True SMOOTHIOU = 0.30 SMOOTH_ALPHA = 0.55

NCALIBFRAMES = 60 PIXSUB = 4 BGSIGMA = 22.0 PARTDIM = 6 COLORDIM = PARTDIM * 2 CALIBMAX_OBS = 6000

PARTMINBALANCE = 0.20 PARTMINSIL = 0.08 PARTMINMARGIN = 1.00 PARTTRIMMAXSHARE = 0.25 PARTTRIMMINKEEP = 30

REFINEPERFRAME = True REFINEALPHA = 0.45 REFINEMINOBS = 2 REFINECONF_FRAC = 0.50

USEVL = True USEPARTVL = True VLSTEPFRAMES = 5 VLBATCHSTART = 64 VLMINBATCH = 16 VLMINOBS = 40 PARTVLMINOBS = 40 PARTVLMINSIL = 0.08 PARTVLMINBALANCE = 0.20 VLCONFIRMAGREE = 0.65 VLREJECTAGREE = 0.35

VLMODELNAME = "Qwen/Qwen3-VL-Embedding-2B" VLINSTRUCTION = (f"Represent the {SPORT} player's kit: " "colors and pattern of the shirt and the shorts.") PARTVL_INSTRUCTION = (f"Represent the visible part of the {SPORT} player's kit: " "its colors and pattern.")

VLEMBCACHE = os.path.join(CACHEDIR, 'vlembeddingsv24.npz') VLKEYSRC = [VIDEOPATH, VLMODELNAME, VLINSTRUCTION, PARTVLINSTRUCTION, str(VLSTEPFRAMES), str(USEPARTVL), str(SEGMENTSTARTFRAME), str(NUMDEBUGFRAMES), str(FRAMESTRIDE)] VLKEY = hashlib.md5('|'.join(VLKEY_SRC).encode()).hexdigest()[:16]

VISNUMFRAMES = 8 TEAMBGR = {0: (0, 140, 255), 1: (255, 80, 80), UNASSIGNEDTEAM_ID: (128, 128, 128)}

print(f"⚙️ Игроки: {PROCESSCLASSES} | VL: {'вкл' if USEVL else 'выкл'} | " f"авто: {'вкл' if AUTOTUNE else 'выкл'} | свет: {'вкл' if ILLUMNORM else 'выкл'} | " f"4-прототипы: {'вкл' if ILLUM_SPLIT else 'выкл'}")

for var in ['model', 'REIDMODEL', 'SIGLIPMODEL', 'SIGLIPPROCESSOR', 'VLMODEL', 'VLPROCESSOR']: if var in globals() and globals()[var] is not None: del globals()[var] gc.collect() if torch.cuda.isavailable(): torch.cuda.empty_cache()

SEGPATH = '/content/yolo26l-seg.pt' if os.path.exists(SEGPATH): SEGMODEL = YOLO(SEGPATH); SEGNAME = 'yolo26l-seg' else: try: SEGMODEL = YOLO('yolo11l-seg.pt'); SEGNAME = 'yolo11l-seg' except Exception: SEGMODEL = YOLO('yolov8l-seg.pt'); SEGNAME = 'yolov8l-seg' SEGHALF = (DEVICE == 'cuda') print(f"✅ Seg-модель: {SEGNAME} | COLORDIM={COLOR_DIM}")

=====================================================

3. Утилиты

=====================================================

def otsuthresh(vals, bins=64): vals = np.asarray(vals, np.float32).ravel() if vals.size == 0: return 0.0 lo, hi = float(vals.min()), float(vals.max()) if hi <= lo: return hi hist, = np.histogram(vals, bins=bins, range=(lo, hi)) hist = hist.astype(np.float64); total = hist.sum() if total <= 0: return hi sumtotal = float((hist * np.arange(bins)).sum()) w0 = s0 = 0.0; best = -1.0; thr = bins // 2 for i in range(bins): w0 += hist[i]; s0 += i * hist[i] w1 = total - w0 if w0 <= 0 or w1 <= 0: continue m0 = s0 / w0; m1 = (sumtotal - s0) / w1 var = w0 w1 (m0 - m1) * 2 if var > best: best = var; thr = i return lo + (thr + 0.5) (hi - lo) / bins

def pick_uniform(lst, k): if k <= 0 or not lst: return [] if k >= len(lst): return list(lst) return [lst[i] for i in np.linspace(0, len(lst) - 1, k).astype(int)]

def iou_box(a, b): ix1, iy1 = max(a[0], b[0]), max(a[1], b[1]) ix2, iy2 = min(a[2], b[2]), min(a[3], b[3]) iw, ih = max(0.0, ix2 - ix1), max(0.0, iy2 - iy1) inter = iw ih if inter <= 0.0: return 0.0 ua = (a[2]-a[0])(a[3]-a[1]) + (b[2]-b[0])*(b[3]-b[1]) - inter return inter / max(1e-6, ua)

def grassreference(frame, tb, excludeboxes, padfrac=GRASSPADFRAC): fh, fw = frame.shape[:2] h, w = float(tb[3] - tb[1]), float(tb[2] - tb[0]) py, px = int(h * padfrac), int(w * padfrac) x1 = max(0, int(tb[0]) - px); y1 = max(0, int(tb[1]) - py) x2 = min(fw, int(tb[2]) + px); y2 = min(fh, int(tb[3]) + py) if x2 - x1 < 8 or y2 - y1 < 8: return None ring = frame[y1:y2, x1:x2] mask = np.ones(ring.shape[:2], bool) bx1, by1 = max(0, int(tb[0]) - x1), max(0, int(tb[1]) - y1) bx2, by2 = min(x2 - x1, int(tb[2]) - x1), min(y2 - y1, int(tb[3]) - y1) if bx2 > bx1 and by2 > by1: mask[by1:by2, bx1:bx2] = False for ob in excludeboxes: ox1 = max(0, int(ob[0]) - x1); oy1 = max(0, int(ob[1]) - y1) ox2 = min(x2 - x1, int(ob[2]) - x1); oy2 = min(y2 - y1, int(ob[3]) - y1) if ox2 > ox1 and oy2 > oy1: mask[oy1:oy2, ox1:ox2] = False if int(mask.sum()) < 80: return None lab = cv2.cvtColor(ring, cv2.COLOR_BGR2LAB) L = lab[:, :, 0][mask].astype(np.float32) A = lab[:, :, 1][mask].astype(np.float32) B = lab[:, :, 2][mask].astype(np.float32) return np.array([np.median(L), np.median(A), np.median(B)], np.float32)

def applyillumshift(bgr, shiftlab, chroma=True): if shiftlab is None: return bgr lab = cv2.cvtColor(bgr, cv2.COLORBGR2LAB).astype(np.float32) s = np.array([shiftlab[0], shiftlab[1] if chroma else 0.0, shiftlab[2] if chroma else 0.0], np.float32) lab -= s.reshape(1, 1, 3) lab = np.clip(lab, 0, 255).astype(np.uint8) return cv2.cvtColor(lab, cv2.COLOR_LAB2BGR)

def segframe(fr): res = SEGMODEL.predict(fr, imgsz=640, half=SEG_HALF, device=0, verbose=False, classes=[0])[0] if res.masks is None or len(res.masks) == 0: return None, None return res.masks.data.cpu().numpy(), res.boxes.xyxy.cpu().numpy()

def matchmask(masks, bxy, tb, miniou=0.2): bestiou, besti = 0.0, -1 for i, db in enumerate(bxy): iou = ioubox(db, tb) if iou > bestiou: bestiou, besti = iou, i if besti < 0 or bestiou < miniou: return None return besti

def backgroundmodel(fr, boxes): h, w, = fr.shape ys = np.arange(4, h - 4, 48); xs = np.arange(4, w - 4, 48) if len(ys) == 0 or len(xs) == 0: return None grid = fr[ys[:, None], xs[None, :]].reshape(-1, 3).astype(np.float32) gy, gx = np.meshgrid(ys, xs, indexing='ij') ptsy = gy.reshape(-1); ptsx = gx.reshape(-1) keep = np.ones(len(grid), dtype=bool) if boxes: for bx in np.asarray(boxes, dtype=np.float32): inside = (ptsx >= bx[0]-5) & (ptsx <= bx[2]+5) & \ (ptsy >= bx[1]-5) & (ptsy <= bx[3]+5) keep &= ~inside arr = grid[keep] if len(arr) < 60: return None ab = cv2.cvtColor(arr.astype(np.uint8).reshape(-1, 1, 3), cv2.COLORBGR2LAB).reshape(-1, 3)[:, 1:3].astype(np.float32) crit = (cv2.TERMCRITERIAEPS + cv2.TERMCRITERIAMAXITER, 12, 1.0) , li, cent = cv2.kmeans(ab, 3, None, crit, 3, cv2.KMEANSPP_CENTERS) order = np.argsort(np.bincount(li.ravel()))[::-1] return cent[order[:2]].astype(np.float32)

def _clip(a, b, c, d, fh, fw): a = max(0, min(fh-1, a)); b = max(a+1, min(fh, b)) c = max(0, min(fw-1, c)); d = max(c+1, min(fw, d)) return (a, b, c, d)

def partwindows(bbox, fh, fw): x1, y1, x2, y2 = [float(v) for v in bbox] h, w = y2 - y1, x2 - x1 sh = clip(int(y1+0.15h), int(y1+0.55h), int(x1+0.22w), int(x1+0.78w), fh, fw) so = _clip(int(y1+0.52h), int(y1+0.80h), int(x1+0.28w), int(x1+0.72w), fh, fw) return sh, so

def bodywindow(bbox, fh, fw): x1, y1, x2, y2 = [float(v) for v in bbox] h = y2 - y1 return clip(int(y1+0.12h), int(y1+0.85h), int(x1), int(x2), fh, fw)

def bgweights(fr, bg, win): a, b, c, d = win crop = fr[a:b, c:d] lab = cv2.cvtColor(crop, cv2.COLORBGR2LAB) ab = np.stack([lab[:, :, 1].astype(np.float32)-128, lab[:, :, 2].astype(np.float32)-128], axis=-1).reshape(-1, 2) d2 = np.min([((ab - cc) * 2).sum(1) for cc in bg], axis=0) return (1.0 - np.exp(-d2 / (2.0 BG_SIGMA ** 2))).reshape(crop.shape[:2])

def partfeature(crop, wp, Tchroma, TV): lab = cv2.cvtColor(crop, cv2.COLORBGR2LAB) hsv = cv2.cvtColor(crop, cv2.COLORBGR2HSV) L = lab[:, :, 0].astype(np.float32) A = lab[:, :, 1].astype(np.float32) - 128 B = lab[:, :, 2].astype(np.float32) - 128 C = np.abs(A) + np.abs(B) V = hsv[:, :, 2].astype(np.float32) W = float(wp.sum()) if W < 8: return None chromab = C > Tchroma wc = wp * chromab Sc = float(wc.sum()) chromafrac = Sc / W whitefrac = float((wp ((~chroma_b) & (V > T_V))).sum()) / W dark_frac = float((w_p ((~chromab) & (V <= TV))).sum()) / W if Sc >= 4: meana = float((wc A).sum() / Sc) / 128.0 mean_b = float((w_c B).sum() / Sc) / 128.0 relL = float(((wc L).sum() / Sc - (w_p L).sum() / W) / 60.0) else: meana = meanb = relL = 0.0 return np.array([meana, meanb, chromafrac, whitefrac, darkfrac, rel_L], np.float32)

=====================================================

4. Кластеризация

=====================================================

def robusttwomeans(X, minbalance, trimmaxshare=0.25, minkeep=30, seed=42, ninit=30): X = np.asarray(X, np.float32) n = len(X) km = KMeans(nclusters=2, ninit=ninit, randomstate=seed).fit(X) labels = km.labels.astype(int).copy() kept = np.ones(n, bool) ntrimmed = 0 for in range(3): if kept.sum() < max(2 min_keep, 6): break n0 = int((labels[kept] == 0).sum()) n1 = int((labels[kept] == 1).sum()) if min(n0, n1) == 0: break bal = 2.0 min(n0, n1) / max(1, n0 + n1) if bal >= minbalance: break small = 0 if n0 < n1 else 1 idxsmall = np.where(kept & (labels == small))[0] if len(idxsmall) == 0 or len(idxsmall) > trimmaxshare kept.sum(): break if kept.sum() - len(idx_small) < min_keep: break kept[idx_small] = False labels[~kept] = -1 n_trimmed = int((~kept).sum()) km = KMeans(n_clusters=2, n_init=n_init, random_state=seed).fit(X[kept]) labels[kept] = km.labels_.astype(int) km = KMeans(n_clusters=2, n_init=n_init, random_state=seed).fit(X[kept]) labels[kept] = km.labels_.astype(int) C = km.cluster_centers_.astype(np.float32) assign = labels.copy() if n_trimmed: d = np.linalg.norm(X[~kept][:, None, :] - C[None, :, :], axis=2) assign[~kept] = np.argmin(d, axis=1).astype(int) n0 = int((labels[kept] == 0).sum()) n1 = int((labels[kept] == 1).sum()) bal = 2.0 min(n0, n1) / max(1, n0 + n1) if min(n0, n1) > 0 else 0.0 sil = float(silhouettescore(X[kept], labels[kept])) if min(n0, n1) >= 2 else -1.0 margin = float(np.linalg.norm(C[0] - C[1])) return {'labels': labels, 'assign': assign, 'centroids': C, 'kept': kept, 'ntrimmed': n_trimmed, 'bal': bal, 'sil': sil, 'margin': margin}

def illumtwomeans(X, minbalance=0.20, trimmaxshare=0.25, minkeep=30, seed=42, ninit=30): """4 кластера (команда x свет/тень) -> слияние ближайших пар -> 2 команды. Guard: слияние обязано согласовываться с 2-кластерной базой >= SPLITAGREEBASE.""" X = np.asarray(X, np.float32) n = len(X) base = robusttwomeans(X, minbalance, trimmaxshare=trimmaxshare, minkeep=minkeep, seed=seed, ninit=ninit) if n < 200: return dict(base, mode='base2', reason='toofewobs', agreebase=None) km4 = KMeans(nclusters=4, ninit=ninit, randomstate=seed).fit(X) labels4 = km4.labels.astype(int) C4 = km4.clustercenters.astype(np.float32) shares = np.array([float((labels4 == k).mean()) for k in range(4)]) small = np.where(shares < SPLITMINSHARE)[0] kept = ~np.isin(labels4, small) ntrimmed = int((~kept).sum()) if ntrimmed > SPLITMAXTRIM n or kept.sum() < 4 minkeep: return dict(base, mode='base2', reason='trimtoomuch', agreebase=None)

def _d(i, j): return float(np.linalg.norm(C4[i] - C4[j]))

bestcost, bestpairing = None, None for pairing in [((0, 1), (2, 3)), ((0, 2), (1, 3)), ((0, 3), (1, 2))]: cost = d(*pairing[0]) + d(pairing[1]) if best_cost is None or cost < best_cost: best_cost, best_pairing = cost, pairing merge_map = {} for team, pair in enumerate(best_pairing): for c in pair: merge_map[int(c)] = int(team) labels2 = np.array([merge_map[int(l)] for l in labels4], int) labels2[~kept] = -1 n0 = int((labels2[kept] == 0).sum()) n1 = int((labels2[kept] == 1).sum()) if min(n0, n1) < min_keep: return dict(base, mode='base2', reason='team_too_small', agree_base=None) bal = 2.0 min(n0, n1) / max(1, n0 + n1) if bal < 0.15: return dict(base, mode='base2', reason='imbalance', agree_base=None)

# v29: согласие с 2-кластерной базой — иначе 4-модель режет не по командам eq = float((labels2[kept] == base['assign'][kept]).mean()) agreebase = max(eq, 1.0 - eq) if agreebase < SPLITAGREEBASE: return dict(base, mode='base2', reason='mergemismatch', agreebase=agree_base)

C = np.stack([X[kept & (labels2 == t)].mean(axis=0) for t in (0, 1)]).astype(np.float32) assign = labels2.copy() if ntrimmed: dtr = np.linalg.norm(X[~kept][:, None, :] - C[None, :, :], axis=2) assign[~kept] = np.argmin(dtr, axis=1).astype(int) sil = float(silhouettescore(X[kept], labels2[kept])) margin = float(np.linalg.norm(C[0] - C[1])) return {'labels': labels2, 'assign': assign, 'centroids': C, 'kept': kept, 'ntrimmed': ntrimmed, 'bal': bal, 'sil': sil, 'margin': margin, 'C4': C4, 'mergemap': mergemap, 'share4': shares, 'mode': 'split4', 'reason': '', 'agreebase': agreebase}

def findvalley(dists, nbins=50, smoothk=5): d = np.sort(np.asarray(dists, np.float64)) if len(d) < 100: return None lo = d[int(0.05 * len(d))] hi = d[min(len(d) - 1, int(0.995 * len(d)))] if hi <= lo * 1.02: return None edges = np.linspace(lo, hi, nbins + 1) hist, = np.histogram(d, bins=edges, density=True) sm = np.convolve(hist, np.ones(smoothk) / smoothk, mode='same') peak = int(np.argmax(sm)) loi, hii = min(peak + 4, nbins - 4), nbins - 2 if hii <= loi + 2: return None v = loi + int(np.argmin(sm[loi:hii])) rightmax = float(sm[v:min(v + 8, nbins)].max()) if sm[v] < 0.45 sm[peak] and right_max > 1.3 sm[v]: return float(edges[v]) return None

def autograythreshold(dists, distsgk): dists = np.asarray(dists, np.float64) if len(dists) < 150: return None, {'method': 'insufficientdata'} p99 = float(np.percentile(dists, 99.0)) if distsgk is not None and len(distsgk) >= 15: p10g = float(np.percentile(distsgk, 10.0)) if p10g > 1.15 * p99 and p10g > p99 + 0.3: return 0.5 * (p99 + p10g), {'method': 'gkgap', 'p99player': p99, 'p10gk': p10g} km = KMeans(nclusters=2, ninit=10, randomstate=0).fit(dists[:, None]) lab = km.labels big = lab == (0 if (lab == 0).sum() >= (lab == 1).sum() else 1) small = ~big share = float(small.mean()) if 0.01 < share < 0.30: p99b = float(np.percentile(dists[big], 99.0)) p05s = float(np.percentile(dists[small], 5.0)) if p05s > 1.15 p99b: return 0.5 (p99b + p05s), {'method': 'bimodal', 'sharesmall': share, 'p99big': p99b, 'p05small': p05s} v = findvalley(dists) if v is not None: return v, {'method': 'valley', 'valley': v} return None, {'method': 'none'}

=====================================================

5. VL-утилиты

=====================================================

def vlload(): try: proc = AutoProcessor.frompretrained(VLMODELNAME, trustremotecode=True) mdl = AutoModel.frompretrained(VLMODELNAME, trustremotecode=True, torchdtype=torch.float16).to(DEVICE) mdl.eval() print(f"✅ {VLMODELNAME} загружена") return mdl, proc except Exception as e: print(f"⚠️ VL-модель не загрузилась ({e}). Работаем только по цвету.") return None, None

def makevlcropregion(frame, win, maskfull=None, pad=4): fh, fw = frame.shape[:2] a, b, c, d = win a = max(0, int(a) - pad); b = min(fh, int(b) + pad) c = max(0, int(c) - pad); d = min(fw, int(d) + pad) if b - a < 6 or d - c < 6: return None crop = frame[a:b, c:d].copy() if maskfull is not None and maskfull.shape == (fh, fw): m = maskfull[a:b, c:d] crop[~m] = 128 crop = cv2.resize(crop, (224, 224)) return Image.fromarray(cv2.cvtColor(crop, cv2.COLORBGR2RGB))

def vlembedbatch(mdl, proc, imgsrgb, instruction): inp = None try: texts = [] for img in imgsrgb: conv = [{"role": "user", "content": [{"type": "image", "image": img}, {"type": "text", "text": instruction}]}] texts.append(proc.applychattemplate(conv, tokenize=False, addgenerationprompt=False)) inp = proc(text=texts, images=imgsrgb, padding=True, returntensors="pt") except Exception: try: inp = proc(text=[""] len(imgs_rgb), images=imgs_rgb, padding=True, return_tensors="pt") except Exception: inp = proc(text=[instruction] len(imgsrgb), images=imgsrgb, padding=True, returntensors="pt") inp = {k: (v.to(DEVICE) if torch.istensor(v) else v) for k, v in inp.items()} with torch.inferencemode(): out = mdl(**inp, returndict=True) hs = out.lasthiddenstate am = inp.get('attention_mask') if am is not None: idx = am.sum(1) - 1 emb = hs[torch.arange(hs.shape[0], device=hs.device), idx] else: emb = hs.mean(1) e = emb.float().cpu().numpy().astype(np.float32) e /= np.maximum(1e-6, np.linalg.norm(e, axis=1, keepdims=True)) return e

def vlembedgroup(mdl, proc, groupjobs, instruction, label): out, b, i = {}, VLBATCHSTART, 0 pbar = tqdm(total=len(groupjobs), desc=label) while i < len(groupjobs): batch = groupjobs[i:i + b] try: embs = vlembedbatch(mdl, proc, [im for , , im in batch], instruction) except torch.cuda.OutOfMemoryError: torch.cuda.emptycache() b = max(VLMINBATCH, b // 2) print(f"⚠️ OOM: VL-батч уменьшен до {b}") continue except RuntimeError as e: if 'out of memory' in str(e).lower(): torch.cuda.emptycache() b = max(VLMINBATCH, b // 2) continue raise for (gidx, kind, ), e in zip(batch, embs): out[(int(gidx), int(kind))] = np.asarray(e, np.float32) i += len(batch) pbar.update(len(batch)) pbar.close() return out

def vlembedall(mdl, proc, jobs): out = {} fulljobs = [j for j in jobs if j[1] == 0] partjobs = [j for j in jobs if j[1] != 0] if fulljobs: out.update(vlembedgroup(mdl, proc, fulljobs, VLINSTRUCTION, "VL тело")) if partjobs: out.update(vlembedgroup(mdl, proc, partjobs, PARTVL_INSTRUCTION, "VL части")) return out

=====================================================

6. ЭТАП 0a: референс газона

=====================================================

trackinglist = list(TRACKINGFRAMEIDS) calibframes = pickuniform(trackinglist, min(NCALIBFRAMES, len(tracking_list)))

GRASSREF = None if ILLUMNORM: refs = [] for fid in tqdm(calibframes, desc="Референс газона"): frame = readframeatindex(VIDEOPATH, fid) if frame is None: continue idxs = getdetectionindices(fid) boxes = [[float(DETX1[g]), float(DETY1[g]), float(DETX2[g]), float(DETY2[g])] for g in idxs if int(DETCLASSID[g]) in PROCESSCLASSES] step = max(1, len(boxes) // 6) for tb in boxes[::step][:6]: r = grassreference(frame, np.array(tb, np.float32), boxes) if r is not None: refs.append(r) if len(refs) >= 20: GRASSREF = np.median(np.stack(refs), axis=0).astype(np.float32) print(f"🌿 Референс газона (LAB): L={GRASSREF[0]:.0f}, " f"a={GRASSREF[1]:.0f}, b={GRASSREF[2]:.0f} ({len(refs)} замеров)") else: ILLUM_NORM = False print("⚠️ Референс газона не собран — нормализация освещения выключена")

=====================================================

7. ЭТАП 0: пороги Otsu (нормализованные кропы)

=====================================================

calibpairs = [] for fid in tqdm(calibframes, desc="Адаптивные пороги"): frame = readframeatindex(VIDEOPATH, fid) if frame is None: continue masks, bxy = segframe(frame) if masks is None: continue fh, fw = frame.shape[:2] idxs = getdetectionindices(fid) boxesall = [[float(DETX1[g]), float(DETY1[g]), float(DETX2[g]), float(DETY2[g])] for g in idxs if int(DETCLASSID[g]) in PROCESSCLASSES] for g in idxs: if int(DETCLASSID[g]) not in PROCESSCLASSES: continue tb = np.array([DETX1[g], DETY1[g], DETX2[g], DETY2[g]], np.float32) hit = matchmask(masks, bxy, tb) if hit is None: continue mask = cv2.resize(masks[hit].astype(np.float32), (fw, fh), interpolation=cv2.INTERLINEAR) > 0.5 shift = None if ILLUMNORM: gr = grassreference(frame, tb, boxesall) if gr is not None: shift = gr - GRASSREF for (a, b, c, d) in partwindows(tb, fh, fw): crop = applyillumshift(frame[a:b, c:d], shift, chroma=ILLUMCHROMA) kit = mask[a:b, c:d] if kit.sum() < 12: continue lab = cv2.cvtColor(crop, cv2.COLORBGR2LAB) hsv = cv2.cvtColor(crop, cv2.COLORBGR2HSV) Ck = (np.abs(lab[:, :, 1].astype(np.float32)-128) + np.abs(lab[:, :, 2].astype(np.float32)-128))[kit] Vk = hsv[:, :, 2].astype(np.float32)[kit] calibpairs.append(np.stack([Ck, Vk], axis=1)[::PIXSUB]) if calibpairs: P = np.concatenate(calibpairs) Tchroma = otsuthresh(P[:, 0]) low = P[:, 0] <= Tchroma TV = otsuthresh(P[low, 1]) if low.sum() > 50 else 140.0 else: Tchroma, TV = 20.0, 140.0 print(f"🎚️ Пороги: Tchroma={Tchroma:.1f}, TV={TV:.1f}") del calib_pairs

=====================================================

8. ЭТАП 0.5: автокалибровка SMOOTH_*

=====================================================

lastcalibfid = None if AUTOTUNE: Kauto = int(np.clip(len(trackinglist) * CALIBFRAC, CALIBMINFRAMES, CALIBMAXFRAMES)) calibautofids = trackinglist[:Kauto] lastcalibfid = int(calibautofids[-1]) print(f"\n🤖 Автокалибровка: первые {Kauto} кадров " f"({100 * Kauto / max(1, len(trackinglist)):.1f}%), " f"fid {calibautofids[0]}..{lastcalib_fid}")

calibcolors, pairsraw = [], [] prevbc = [] for fid, frame in tqdm(itervideoframes(), total=Kauto, desc="Автокалибровка (SMOOTH)"): if fid > lastcalibfid: break idxs = getdetectionindices(fid) if len(idxs) == 0: prevbc = [] continue masks, bxy = segframe(frame) fh, fw = frame.shape[:2] bg = None boxesall = [[float(DETX1[g]), float(DETY1[g]), float(DETX2[g]), float(DETY2[g])] for g in idxs] curbc = [] for g in idxs: if int(DETCLASSID[g]) not in PROCESSCLASSES: continue tb = np.array([DETX1[g], DETY1[g], DETX2[g], DETY2[g]], np.float32) maskfull = None if masks is not None: hit = matchmask(masks, bxy, tb) if hit is not None: maskfull = cv2.resize(masks[hit].astype(np.float32), (fw, fh), interpolation=cv2.INTERLINEAR) > 0.5 if maskfull is None: if bg is None: bg = backgroundmodel(frame, boxesall) if bg is not None: wb = bgweights(frame, bg, bodywindow(tb, fh, fw)) if wb.sum() >= 12: maskfull = np.zeros((fh, fw), np.float32) a0, b0, c0, d0 = bodywindow(tb, fh, fw) maskfull[a0:b0, c0:d0] = wb maskfull = maskfull > 0.5 color = None if maskfull is not None: shift = None if ILLUMNORM: gr = grassreference(frame, tb, boxesall) if gr is not None: shift = gr - GRASSREF sh, so = partwindows(tb, fh, fw) cropsh = applyillumshift(frame[sh[0]:sh[1], sh[2]:sh[3]], shift, chroma=ILLUMCHROMA) cropso = applyillumshift(frame[so[0]:so[1], so[2]:so[3]], shift, chroma=ILLUMCHROMA) fsh = partfeature(cropsh, maskfull[sh[0]:sh[1], sh[2]:sh[3]].astype(np.float32), Tchroma, TV) fso = partfeature(cropso, maskfull[so[0]:so[1], so[2]:so[3]].astype(np.float32), Tchroma, TV) if fsh is not None or fso is not None: color = np.concatenate( [fsh if fsh is not None else np.zeros(PARTDIM, np.float32), fso if fso is not None else np.zeros(PARTDIM, np.float32)]) if color is None: continue calibcolors.append(color) curbc.append((tb, color)) bestiou, bestc = 0.0, None for pb, pc in prevbc: iou = ioubox(tb, pb) if iou > bestiou: bestiou, bestc = iou, pc if bestc is not None and bestiou > 0.05: pairsraw.append((bestiou, float(np.linalg.norm(color - bestc)))) prevbc = curbc

if SMOOTHCOLOR and len(pairsraw) >= 50: ious = np.array([p[0] for p in pairsraw]) deltas = np.array([p[1] for p in pairsraw]) d60 = float(np.percentile(deltas, 60)) ok = deltas <= d60 if int(ok.sum()) >= 30: SMOOTHIOU = float(np.clip(np.percentile(ious[ok], 15), 0.12, 0.40)) d50, d90 = np.percentile(deltas[ok], [50, 90]) SMOOTHALPHA = float(np.clip(0.30 + 0.5 d50 / max(float(d90), 1e-6), 0.35, 0.70)) AUTO_INFO['smooth'] = {'iou': SMOOTH_IOU, 'alpha': SMOOTH_ALPHA, 'n_pairs': int(len(pairs_raw))} print(f" 🤖 SMOOTH_IOU={SMOOTH_IOU:.2f}, SMOOTH_ALPHA={SMOOTH_ALPHA:.2f} " f"(по {len(pairs_raw)} парам соседних кадров)") else: print(" ℹ️ мало согласованных пар — ручные SMOOTH_") else: print(" ℹ️ мало IoU-пар — ручные SMOOTH_*")

=====================================================

9. ЭТАП 1: основной проход (нормализованный цвет + EMA + VL)

=====================================================

trackingset = set(trackinglist) vlfids = set(trackinglist[::max(1, VLSTEPFRAMES)]) if AUTOTUNE and EXCLUDECALIBFRAMES and lastcalibfid is not None: noncalib = [f for f in trackinglist if f > lastcalibfid] if not noncalib: noncalib = trackinglist else: noncalib = trackinglist visfids = set(pickuniform(noncalib, min(VISNUMFRAMES, len(noncalib))))

obsgidx, obsfid, obscls, obsconf, obsbbox, obscolor = [], [], [], [], [], [] vljobs = [] viscache = {} nskippedother = 0 prevbybox = {} nsmoothed, nsmoothcandidates = 0, 0 shift_norms = []

for fid, frame in tqdm(itervideoframes(), total=len(trackinglist), desc="Цвет (нормализованный) + EMA + VL"): if fid not in trackingset: continue if fid in visfids: viscache[fid] = frame.copy() curbybox = {} idxs = getdetectionindices(fid) if len(idxs) == 0: prevbybox = {} continue masks, bxy = segframe(frame) fh, fw = frame.shape[:2] bg = None wantvl = USEVL and (fid in vlfids) boxesall = [[float(DETX1[g]), float(DETY1[g]), float(DETX2[g]), float(DET_Y2[g])] for g in idxs]

for g in idxs: clsid = int(DETCLASSID[g]) if clsid not in PROCESSCLASSES: nskippedother += 1 continue tb = np.array([DETX1[g], DETY1[g], DETX2[g], DET_Y2[g]], np.float32)

maskfull = None if masks is not None: hit = matchmask(masks, bxy, tb) if hit is not None: maskfull = cv2.resize(masks[hit].astype(np.float32), (fw, fh), interpolation=cv2.INTERLINEAR) > 0.5 if maskfull is None: if bg is None: bg = backgroundmodel(frame, boxesall) if bg is not None: wb = bgweights(frame, bg, bodywindow(tb, fh, fw)) if wb.sum() >= 12: maskfull = np.zeros((fh, fw), np.float32) a0, b0, c0, d0 = bodywindow(tb, fh, fw) maskfull[a0:b0, c0:d0] = wb maskfull = mask_full > 0.5

color = None if maskfull is not None: shift = None if ILLUMNORM: gr = grassreference(frame, tb, boxesall) if gr is not None: shift = gr - GRASSREF shiftnorms.append(float(np.linalg.norm(shift))) sh, so = partwindows(tb, fh, fw) cropsh = applyillumshift(frame[sh[0]:sh[1], sh[2]:sh[3]], shift, chroma=ILLUMCHROMA) cropso = applyillumshift(frame[so[0]:so[1], so[2]:so[3]], shift, chroma=ILLUMCHROMA) fsh = partfeature(cropsh, maskfull[sh[0]:sh[1], sh[2]:sh[3]].astype(np.float32), Tchroma, TV) fso = partfeature(cropso, maskfull[so[0]:so[1], so[2]:so[3]].astype(np.float32), Tchroma, TV) if fsh is not None or fso is not None: color = np.concatenate( [fsh if fsh is not None else np.zeros(PARTDIM, np.float32), fso if fso is not None else np.zeros(PARTDIM, np.float32)]) if wantvl: cropfull = makevlcropregion(frame, bodywindow(tb, fh, fw), maskfull) if cropfull is not None: vljobs.append((int(g), 0, cropfull)) if USEPARTVL: for kc, win in ((1, sh), (2, so)): cropp = makevlcropregion(frame, win, maskfull) if cropp is not None: vljobs.append((int(g), kc, cropp))

if color is not None: if SMOOTHCOLOR: bestiou, bestc = 0.0, None for pb, pc in prevbybox.items(): iou = ioubox(tb, pb) if iou > bestiou: bestiou, bestc = iou, pc if bestc is not None and bestiou >= SMOOTHIOU: color = (SMOOTH_ALPHA * color

  • (1.0 - SMOOTHALPHA) * bestc).astype(np.float32) nsmoothed += 1 curbybox[(float(tb[0]), float(tb[1]), float(tb[2]), float(tb[3]))] = color.copy() nsmooth_candidates += 1

obsgidx.append(int(g)); obsfid.append(int(fid)); obscls.append(clsid) obsconf.append(float(DETCONF[g])); obsbbox.append(tb.tolist()) obscolor.append(color)

prevbybox = curbybox

nobs = len(obsgidx) nvalid = sum(1 for c in obscolor if c is not None) print(f"🧩 Детекций-игроков: {nobs} (цвет: {nvalid}, без цвета: " f"{nobs - nvalid} -> серые) | пропущено referee/ball: {nskippedother} | " f"VL-кропов: {len(vljobs)}") if SMOOTHCOLOR: print(f"🧿 EMA-сглаживание: {nsmoothed}/{nsmoothcandidates} наблюдений " f"(IoU≥{SMOOTHIOU:.2f}, α={SMOOTHALPHA:.2f})") if ILLUMNORM and shiftnorms: sh = np.array(shiftnorms) print(f"🌿 Сдвиг освещения (LAB): p50={np.percentile(sh, 50):.1f}, " f"p95={np.percentile(sh, 95):.1f}, max={sh.max():.1f}") assert n_valid >= 100, "❌ Слишком мало цветовых наблюдений"

validrows = [i for i, c in enumerate(obscolor) if c is not None] COLS = np.stack([obscolor[i] for i in validrows]).astype(np.float32) rowofgidx = {obsgidx[i]: ri for ri, i in enumerate(validrows)} fidvalid = np.array([obsfid[i] for i in validrows], np.int64) gidxvalid = np.array([obsgidx[i] for i in validrows], np.int64)

=====================================================

10. ЭТАП 2: VL-эмбеддинги

=====================================================

vlobs = {} if USEVL: VLMODEL, VLPROCESSOR = vlload() if VLMODEL is not None: if os.path.exists(VLEMBCACHE): try: with np.load(VLEMBCACHE) as z: if str(z['key']) == VLKEY: for g, k, e in zip(z['gidx'], z['kind'], z['emb']): vlobs[(int(g), int(k))] = np.asarray(e, np.float32) print(f"📦 VL-кэш: {len(vlobs)} эмбеддингов") except Exception as e: print(f"⚠️ VL-кэш повреждён ({e}) — пересчёт") todo = [j for j in vljobs if (j[0], j[1]) not in vlobs] if todo: print(f"🎯 К пересчёту: {len(todo)} из {len(vljobs)} кропов") vlobs.update(vlembedall(VLMODEL, VLPROCESSOR, todo)) if vlobs: ks = sorted(vlobs.keys()) np.savezcompressed(VLEMBCACHE, key=VLKEY, gidx=np.array([k[0] for k in ks], np.int64), kind=np.array([k[1] for k in ks], np.int8), emb=np.stack([vlobs[k] for k in ks]).astype(np.float32)) print(f"💾 VL-кэш сохранён: {VLEMBCACHE}") del VLMODEL, VLPROCESSOR gc.collect(); torch.cuda.empty_cache()

vlfull = {g: e for (g, k), e in vlobs.items() if k == 0} vlpart = {k: {g: e for (g, kk), e in vlobs.items() if kk == k} for k in (1, 2)} print(f"🧠 VL: full-наблюдений={len(vlfull)}, " f"part: shirt={len(vlpart[1])}, shorts={len(vl_part[2])}")

=====================================================

11. ЭТАП 3: прототипы + ЭТАП 3.5: автопорог (в 2-шкале!)

=====================================================

calibrows = np.array(pickuniform(list(range(nvalid)), min(CALIBMAXOBS, nvalid)), np.int64) COLScal = COLS[calibrows] print(f"🔬 Калибровка прототипов на {len(calibrows)} наблюдениях (из {nvalid})")

def analyzepart(partslice, name): colsp = COLScal[:, partslice] stdp = colsp.std(axis=0) keepp = stdp > 1e-4 if keepp.sum() < 1: print(f" цвет[{name}]: признаки константны -> часть исключена") return None scp = StandardScaler().fit(colsp[:, keepp]) Dp = int(keepp.sum()) Xp = (scp.transform(colsp[:, keepp]).astype(np.float32) / np.sqrt(Dp)) r = robusttwomeans(Xp, PARTMINBALANCE, trimmaxshare=PARTTRIMMAXSHARE, minkeep=PARTTRIMMINKEEP) sep = (r['bal'] >= PARTMINBALANCE) and (r['sil'] >= PARTMINSIL) \ and (r['margin'] >= PARTMINMARGIN) trinfo = f" тримминг={r['ntrimmed']}" if r['ntrimmed'] else "" print(f" цвет[{name}]: sil={r['sil']:.3f} bal={r['bal']:.2f} " f"margin={r['margin']:.2f}{trinfo} -> " f"{'✅ различима' if sep else '❌ не различима'}") return {'name': name, 'slice': partslice, 'keep': keepp, 'sc': scp, 'D': Dp, 'centroids': r['centroids'], 'sep': sep}

def partlabelsall(block): Z = (block['sc'].transform(COLS[:, block['slice']][:, block['keep']]) .astype(np.float32) / np.sqrt(block['D'])) d = np.linalg.norm(block['centroids'][None, :, :] - Z[:, None, :], axis=2) return np.argmin(d, axis=1)

def vlpartanalysis(kindcode, name): if not (USEVL and USEPARTVL): return None tr = vlpart.get(kindcode, {}) gidxs = sorted(tr.keys()) if len(gidxs) < PARTVLMINOBS: print(f" VL[{name}]: наблюдений {len(gidxs)} < {PARTVLMINOBS} -> пропущено") return None V = np.stack([tr[g] for g in gidxs]).astype(np.float32) sc = StandardScaler().fit(V) D = int(V.shape[1]) X = sc.transform(V).astype(np.float32) / np.sqrt(D) r = robusttwomeans(X, PARTVLMINBALANCE, trimmaxshare=PARTTRIMMAXSHARE, minkeep=PARTVLMINOBS) sep = (r['bal'] >= PARTVLMINBALANCE) and (r['sil'] >= PARTVLMINSIL) print(f" VL[{name}]: n={len(gidxs)} sil={r['sil']:.3f} bal={r['bal']:.2f} " f"тримминг={r['n_trimmed']} -> " f"{'✅ различима по VL' if sep else '❌ не различима по VL'}") return {'name': name, 'gidxs': gidxs, 'assign': {g: int(r['assign'][i]) for i, g in enumerate(gidxs)}, 'sep': sep}

print("\n🔎 OR-анализ частей формы (цвет / VL):") shirtblk = analyzepart(slice(0, PARTDIM), 'shirt') shortsblk = analyzepart(slice(PARTDIM, COLORDIM), 'shorts') vlshirt = vlpartanalysis(1, 'shirt') vlshorts = vlpart_analysis(2, 'shorts')

print("\n🤝 Валидация цветовых частей через VL:") partusecolor = {} for pname, cb, vb in (('shirt', shirtblk, vlshirt), ('shorts', shortsblk, vlshorts)): colorsep = bool(cb is not None and cb['sep']) vlsep = bool(vb is not None and vb['sep']) if colorsep and vlsep: labcolor = partlabelsall(cb) rows, labsvl = [], [] for g in vb['gidxs']: if g in rowofgidx and vb['assign'][g] >= 0: rows.append(rowofgidx[g]) labsvl.append(vb['assign'][g]) if len(rows) >= 20: a = labcolor[np.array(rows)] b = np.array(labsvl) agree = float((a == b).mean()) agree = max(agree, 1.0 - agree) if agree < VLREJECTAGREE: print(f" ⛔ {pname}: цвет ОПРОВЕРГНУТ VL (agree={agree:.2f}, " f"n={len(rows)}) — часть исключается") colorsep = False elif agree >= VLCONFIRMAGREE: print(f" ✅ {pname}: цвет подтверждён VL (agree={agree:.2f}, n={len(rows)})") else: print(f" ⚖️ {pname}: VL нейтрален (agree={agree:.2f}) — часть оставлена") else: print(f" ℹ️ {pname}: мало пересечений для валидации — часть оставлена") partusecolor[pname] = color_sep

active = [] if partusecolor.get('shirt') and shirtblk is not None: active.append(shirtblk) if partusecolor.get('shorts') and shortsblk is not None: active.append(shortsblk) if active: PARTMODE = '+'.join(b['name'] for b in active) print(f"\n✅ Цветовые блоки прототипов: [{PARTMODE}]") else: PARTMODE = 'combined' print("\n⚠️ Ни одна часть не различима -> объединённый вектор 12") stdcol = COLScal.std(axis=0) keepall = stdcol > 1e-4 assert keepall.sum() >= 1, "❌ Все цветовые признаки константны" scall = StandardScaler().fit(COLScal[:, keepall]) active = [{'name': 'combined', 'slice': slice(0, COLORDIM), 'keep': keepall, 'sc': scall, 'D': int(keep_all.sum())}]

ACTIVE_BLOCKS = active

def colorsembed(colors): colors = np.asarray(colors, np.float32) vecs = [] for b in ACTIVEBLOCKS: part = colors[:, b['slice']] z = b['sc'].transform(part[:, b['keep']]).astype(np.float32) / np.sqrt(b['D']) vecs.append(z) return np.hstack(vecs) if len(vecs) > 1 else vecs[0]

Eall = colorsembed(COLS).astype(np.float32) Ecal = Eall[calibrows] DC = int(E_all.shape[1])

vlassign, vlfullsep = {}, False if USEVL and len(vlfull) >= VLMINOBS: gidxs = sorted(vlfull.keys()) V = np.stack([vlfull[g] for g in gidxs]).astype(np.float32) scvlf = StandardScaler().fit(V) rv = robusttwomeans(scvlf.transform(V).astype(np.float32), 0.15, trimmaxshare=PARTTRIMMAXSHARE, minkeep=VLMINOBS) vlassign = {g: int(rv['assign'][i]) for i, g in enumerate(gidxs)} vlfullsep = (rv['bal'] >= 0.15) and (rv['sil'] >= 0.05) print(f"🧠 VL(full): n={len(gidxs)} sil={rv['sil']:.3f} bal={rv['bal']:.2f} " f"тримминг={rv['ntrimmed']} -> {'различим' if vlfull_sep else 'не различим'}")

--- Уровень 2: 4-кластерная модель (с guard-согласием) ---

if ILLUMSPLIT: rf = illumtwomeans(Ecal, minbalance=PARTMINBALANCE, trimmaxshare=PARTTRIMMAXSHARE, minkeep=PARTTRIMMINKEEP) else: rf = robusttwomeans(Ecal, PARTMINBALANCE, trimmaxshare=PARTTRIMMAXSHARE, minkeep=PARTTRIMMINKEEP) colorok = (rf['bal'] >= 0.15) and (rf['sil'] >= 0.05) print(f"🎨 Прототипы цвета[{PARTMODE}]: sil={rf['sil']:.3f} bal={rf['bal']:.2f} " f"margin={rf['margin']:.2f} тримминг={rf['ntrimmed']} -> " f"{'✅ цвет разделим' if colorok else '❌ цвет не разделим'}") if ILLUMSPLIT: if rf.get('mode') == 'split4': print(f" 🌗 Уровень 2 (4 кластера): доли={np.round(rf['share4'], 3).tolist()}, " f"слияние={rf['mergemap']}, согласие с 2-моделью={rf['agree_base']:.2f}") else: print(f" 🌗 Уровень 2: откат к 2 кластерам ({rf.get('reason', '')})")

if colorok: C = rf['centroids'] protosrc = 'color' elif USEVL and vlfullsep: rowsvl, labsvl = [], [] for g, l in vlassign.items(): if l >= 0 and g in rowofgidx: rowsvl.append(rowofgidx[g]); labsvl.append(l) rowsvl = np.array(rowsvl); labsvl = np.array(labsvl) C = np.stack([Eall[rowsvl][labsvl == c].mean(axis=0) for c in (0, 1)]) protosrc = 'vlteach' print("🛟 Прототипы ЦВЕТА обучены по VL-разметке (цвет сам не разделим)") else: C = rf['centroids'] protosrc = 'colorbesteffort' print("⚠️ Цвет и VL не разделимы — прототипы best-effort, проверьте видео")

USESPLIT4 = bool(ILLUMSPLIT and rf.get('mode') == 'split4' and protosrc == 'color') C4 = rf.get('C4') if USESPLIT4 else None MERGE = rf.get('mergemap') or {} TEAMS4 = (np.array([MERGE.get(k, 0) for k in range(4)], int) if USESPLIT4 else None) print(f"🌗 Команда: {'4-модель (свет/тень)' if USE_SPLIT4 else '2-модель'}; " f"серые: 2-шкала (dist до среднего центроида)")

def teamdists(E): """Дистанции для НАЗНАЧЕНИЯ команды (4-модель или 2-модель).""" if USESPLIT4 and C4 is not None and TEAMS4 is not None: d4 = np.stack([np.linalg.norm(E - C4[k], axis=1) for k in range(4)], axis=1) d0t = np.min(d4[:, TEAMS4 == 0], axis=1) d1t = np.min(d4[:, TEAMS4 == 1], axis=1) return d0t, d1t d0t = np.linalg.norm(E - C[0], axis=1) d1t = np.linalg.norm(E - C[1], axis=1) return d0t, d1t

def gray_dists(E): """Дистанции для СЕРОСТИ (всегда 2-шкала — шкала порога v27).""" dC0 = np.linalg.norm(E - C[0], axis=1) dC1 = np.linalg.norm(E - C[1], axis=1) return dC0, dC1

margin_cc = float(np.linalg.norm(C[0] - C[1]))

--- порог серых: fallback-формула в 2-шкале (как в v27) ---

if protosrc == 'color': dC0c, dC1c = graydists(Ecal) dcal = np.minimum(dC0c, dC1c) dmass = dcal[rf['kept']] med = float(np.median(dmass)) mad = float(np.median(np.abs(dmass - med))) 1.4826 GRAY_THRESH = max(med + OUTLIER_K mad, GRAYMEDFACTOR med) elif proto_src == 'vl_teach': d_vl = np.linalg.norm(E_all[rows_vl] - C[labs_vl], axis=1) med = float(np.median(d_vl)) mad = float(np.median(np.abs(d_vl - med))) 1.4826 GRAYTHRESH = max(med + OUTLIERK mad, GRAY_MED_FACTOR med) else: dC0c, dC1c = graydists(Ecal) dcal = np.minimum(dC0c, dC1c) med = float(np.median(dcal)) GRAYTHRESH = max(GRAYMED_FACTOR * med, 1e-3)

--- ЭТАП 3.5: автопорог серых + AMBIV (в 2-шкале) ---

GRAYSRC = 'manual' if AUTOTUNE and lastcalibfid is not None: rowsauto = np.where(fidvalid <= lastcalibfid)[0] if len(rowsauto) >= 150: dC0a, dC1a = graydists(Eall[rowsauto]) distauto = np.minimum(dC0a, dC1a) clsauto = np.array([obscls[validrows[ri]] for ri in rowsauto]) distgk = distauto[clsauto == int(CLSGK)] distpl = distauto[clsauto == int(CLSPLAYER)] base = distpl if len(distpl) >= 150 else distauto tauto, ginfo = autograythreshold(base, distgk) AUTOINFO['gray'] = ginfo if tauto is not None: GRAYTHRESH = float(tauto) GRAYSRC = 'auto:' + str(ginfo['method']) AMBIVFRAC = 0.0 print(f"🤖 GRAYTHRESH={GRAYTHRESH:.2f} (авто, метод: {ginfo['method']})") else: print("🤖 авто-порог не найден — fallback на ручную формулу") r = np.abs(dC0a - dC1a) / max(margincc, 1e-6) mteam = distauto <= GRAYTHRESH if int(mteam.sum()) >= 100: AMBIVFRAC = float(np.clip( 0.5 * float(np.percentile(r[mteam], 5)), 0.10, 0.35)) AUTOINFO['ambiv'] = AMBIVFRAC print(f"🤖 AMBIVFRAC={AMBIV_FRAC:.2f}") else: print("🤖 мало калибровочных наблюдений для авто-порога — ручная формула")

if not GRAYSRC.startswith('auto:'): GRAYTHRESH = GRAYTHRESH * GRAYTHRESH_SCALE

print(f"📏 Порог серых: {GRAYTHRESH:.2f} (прототипы: {protosrc}, " f"источник: {GRAYSRC}, ambiv={AMBIVFRAC}, шкала: 2-центроидная)")

=====================================================

12. ЭТАП 4: классификация (команда — 4-модель, серые — 2-шкала)

=====================================================

d0t, d1t = teamdists(Eall) # шкала назначения команды teamvalid = np.where(d0t <= d1t, 0, 1).astype(np.int8) dC0, dC1 = graydists(Eall) # шкала серых (2-центроидная) distvalid = np.minimum(dC0, dC1).astype(np.float32)

v29: диагностика — сколько наблюдений 4-модель перевела в другую команду

team2 = np.where(dC0 <= dC1, 0, 1) nflips = int((teamvalid != team2).sum()) if USESPLIT4: print(f"🌗 4-модель изменила команду {nflips} наблюдениям " f"({100.0 * nflips / max(1, nvalid):.1f}%) относительно 2-модели")

if AMBIVFRAC > 0: ambiv = np.abs(dC0 - dC1) < AMBIVFRAC * margincc teamvalid[(distvalid > GRAYTHRESH) & ambiv] = UNASSIGNEDTEAMID else: teamvalid[distvalid > GRAYTHRESH] = UNASSIGNEDTEAM_ID

--- REFINE: при 4-модели ОТКЛЮЧЁН (локальный центр смешивал зоны света/тени) ---

if REFINEPERFRAME and USESPLIT4: print(" ℹ️ REFINE отключён при 4-модели (Уровень 2 компенсирует освещение)") elif REFINEPERFRAME: if AUTOTUNE and lastcalibfid is not None: rowsautor = np.where(fidvalid <= lastcalibfid)[0] if len(rowsautor) >= 150: fidautor = fidvalid[rowsautor] tvauto = teamvalid[rowsautor] drifts = [] for c in (0, 1): mc = tvauto == c if int(mc.sum()) < 30: continue gmean = Eall[rowsautor[mc]].mean(axis=0) for f in np.unique(fidautor[mc]): mf = mc & (fidautor == f) if int(mf.sum()) >= 2: drifts.append(float(np.linalg.norm( Eall[rowsautor[mf]].mean(axis=0) - gmean))) if len(drifts) >= 10: drift = float(np.median(drifts)) spread = float(np.median(distvalid[rowsautor])) REFINEALPHA = float(np.clip(0.15 + 1.5 drift / max(spread, 1e-6), 0.15, 0.60)) AUTO_INFO['refine_alpha'] = REFINE_ALPHA print(f"🤖 REFINE_ALPHA={REFINE_ALPHA:.2f} " f"(дрейф={drift:.2f}, разброс={spread:.2f})") for fid in np.unique(fid_valid): inds = np.where(fid_valid == fid)[0] tv = team_valid[inds]; dv = dist_valid[inds] confident = (tv != UNASSIGNED_TEAM_ID) & (dv <= REFINE_CONF_FRAC GRAYTHRESH) localC = {} for c in (0, 1): m = confident & (tv == c) if int(m.sum()) >= REFINEMINOBS: localC[c] = (1.0 - REFINEALPHA) C[c] + \ REFINE_ALPHA Eall[inds[m]].mean(axis=0) else: localC[c] = C[c] Ef = Eall[inds] d0f = np.linalg.norm(Ef - localC[0], axis=1) d1f = np.linalg.norm(Ef - localC[1], axis=1) nt = np.where(d0f <= d1f, 0, 1).astype(np.int8) nd = np.minimum(d0f, d1f).astype(np.float32) if AMBIVFRAC > 0: amb = np.abs(d0f - d1f) < AMBIVFRAC * margincc nt[(nd > GRAYTHRESH) & amb] = UNASSIGNEDTEAMID else: nt[nd > GRAYTHRESH] = UNASSIGNEDTEAMID teamvalid[inds] = nt distvalid[inds] = nd

=====================================================

13. ЭТАП 5: нейминг + финальная разметка

=====================================================

sw = COLS[:, 3] + COLS[:, 9] sw0 = float(sw[teamvalid == 0].mean()) if (teamvalid == 0).any() else 0.0 sw1 = float(sw[teamvalid == 1].mean()) if (teamvalid == 1).any() else 0.0 TEAMNAMES = {0: 'teamA', 1: 'teamB', UNASSIGNEDTEAMID: 'unassigned'} if abs(sw0 - sw1) >= 0.05: light = 0 if sw0 > sw1 else 1 remap = {light: 1, 1 - light: 0} print(f"ℹ️ Нейминг: teamA = более тёмная форма, teamB = более светлая " f"(только имена, sw: c0={sw0:.2f}, c1={sw1:.2f})") else: remap = {0: 0, 1: 1} print("⚠️ Команды неразличимы по светлоте: нейминг анонимный") teamvalid = np.array([remap[int(t)] if int(t) in (0, 1) else int(t) for t in team_valid], np.int8) C = np.stack([C[0] if remap[0] == 0 else C[1], C[1] if remap[1] == 1 else C[0]]).astype(np.float32)

teamfull = [UNASSIGNEDTEAMID] * nobs distfull = [None] * nobs for ri, i in enumerate(validrows): teamfull[i] = int(teamvalid[ri]) distfull[i] = float(dist_valid[ri])

arivc = agreevc = None if USEVL and len(vlassign) >= VLMINOBS: rows, labs = [], [] for g, l in vlassign.items(): if l >= 0 and g in rowofgidx: rows.append(rowofgidx[g]); labs.append(l) tv = teamvalid[np.array(rows)] m = tv != UNASSIGNEDTEAMID if int(m.sum()) >= 20: a = np.array(labs)[m]; b = tv[m] agreevc = float((a == b).mean()) if float((a != b).mean()) > agreevc: agreevc = float((a != b).mean()) arivc = float(adjustedrandscore(a, b)) print(f"🤝 ИТОГ: согласие цвет vs VL = {agreevc:.2f} | ARI = {arivc:.2f}")

=====================================================

14. ЭТАП 6: вывод + визуализация + отчёт

=====================================================

if AUTOTUNE and EXCLUDECALIBFRAMES and lastcalibfid is not None: INCLUDEMASK = np.array([f > lastcalibfid for f in obsfid], bool) else: INCLUDEMASK = np.ones(nobs, bool) includevalid = np.array([INCLUDEMASK[i] for i in validrows], bool) nexcluded = int((~INCLUDEMASK).sum())

framesout = {} for i in range(nobs): if not INCLUDEMASK[i]: continue framesout.setdefault(obsfid[i], []).append({ 'gidx': int(obsgidx[i]), 'classid': int(obscls[i]), 'team': int(teamfull[i]), 'dist': (round(distfull[i], 3) if distfull[i] is not None else None), 'conf': round(obsconf[i], 3)})

n0 = int(sum(1 for i in range(nobs) if INCLUDEMASK[i] and teamfull[i] == 0)) n1 = int(sum(1 for i in range(nobs) if INCLUDEMASK[i] and teamfull[i] == 1)) n2 = int(sum(1 for i in range(nobs) if INCLUDEMASK[i] and teamfull[i] == UNASSIGNEDTEAMID)) suspectmask = (distvalid > 0.8 * GRAYTHRESH) & (distvalid <= GRAYTHRESH) suspect = int((suspectmask & includevalid).sum()) if n_valid else 0

illumstats = {} if shiftnorms: sh = np.array(shiftnorms) illumstats = {'enabled': True, 'chroma': bool(ILLUMCHROMA), 'grassref': GRASSREF.tolist() if GRASSREF is not None else None, 'shiftp50': float(np.percentile(sh, 50)), 'shiftp95': float(np.percentile(sh, 95))} else: illum_stats = {'enabled': False}

splitstats = {'enabled': bool(USESPLIT4), 'mode': str(rf.get('mode', 'base2')), 'share4': (rf['share4'].tolist() if rf.get('share4') is not None else None), 'mergemap': {str(k): int(v) for k, v in MERGE.items()}, 'trim': int(rf.get('ntrimmed', 0)), 'agreebase': (float(rf['agreebase']) if rf.get('agreebase') is not None else None), 'teamflips': int(nflips), 'gray_scale': 'centroids2'}

meta = { 'videopath': VIDEOPATH, 'numframes': len(framesout), 'numobs': int(INCLUDEMASK.sum()), 'numvalid': int(includevalid.sum()), 'teamcounts': {'A': n0, 'B': n1, 'unassigned': n2}, 'partmode': PARTMODE, 'protosrc': protosrc, 'graythresh': float(GRAYTHRESH), 'graysrc': GRAYSRC, 'outlierk': OUTLIERK, 'graymedfactor': GRAYMEDFACTOR, 'graythreshscale': GRAYTHRESHSCALE, 'ambivfrac': AMBIVFRAC, 'illum': illumstats, 'illumsplit': splitstats, 'autotune': {'enabled': bool(AUTOTUNE), 'calibframes': int(len(calibautofids)) if AUTOTUNE else 0, 'lastcalibfid': int(lastcalibfid) if lastcalibfid is not None else None, 'excluded': bool(nexcluded > 0), 'params': AUTOINFO}, 'smooth': {'enabled': SMOOTHCOLOR, 'iou': SMOOTHIOU, 'alpha': SMOOTHALPHA, 'smoothed': int(nsmoothed), 'candidates': int(nsmoothcandidates)}, 'refine': {'enabled': bool(REFINEPERFRAME and not USESPLIT4), 'alpha': REFINEALPHA, 'minobs': REFINEMINOBS, 'conffrac': REFINECONFFRAC}, 'processclasses': list(PROCESSCLASSES), 'teamnames': TEAMNAMES, 'vlvalidation': {'agree': agreevc, 'ari': arivc}, 'suspectborderline': suspect, 'Tchroma': float(Tchroma), 'TV': float(TV), } with open(os.path.join(OUTPUTDIR, 'frameteamassignment.json'), 'w', encoding='utf-8') as f: json.dump({'meta': meta, 'frames': {str(k): v for k, v in sorted(framesout.items())}}, f, ensureascii=False, separators=(',', ':')) print(f"💾 frameteamassignment.json: {len(framesout)} кадров, " f"{int(INCLUDE_MASK.sum())} детекций"

  • (f" (исключено калибровочных: {nexcluded})" if nexcluded else ""))

blocksout = [] for b in ACTIVEBLOCKS: blocksout.append({'name': b['name'], 'slice': [int(b['slice'].start), int(b['slice'].stop)], 'keepdims': [int(i) for i in np.where(b['keep'])[0]], 'mean': b['sc'].mean.tolist(), 'scale': b['sc'].scale.tolist()}) rawc = [] for c in (0, 1): m = teamvalid == c rawc.append(COLS[m].mean(axis=0).tolist() if m.any() else None) with open(os.path.join(OUTPUTDIR, 'teamprototypes.json'), 'w', encoding='utf-8') as f: json.dump({'version': 'v29splitscales', 'sport': SPORT, 'blocks': blocksout, 'D': int(DC), 'centroidsscaled': C.tolist(), 'centroidsrawcolor12': rawc, 'graythresh': float(GRAYTHRESH), 'graysrc': GRAYSRC, 'teamnames': TEAMNAMES, 'illum': illumstats, 'illumsplit': splitstats, 'smooth': {'enabled': SMOOTHCOLOR, 'iou': SMOOTHIOU, 'alpha': SMOOTHALPHA}, 'auto': AUTOINFO, 'vlmodel': VLMODELNAME if USEVL else None, 'gate': {'protosrc': protosrc, 'partmode': PARTMODE, 'rfsil': rf['sil'], 'rfbal': rf['bal'], 'vlagree': agreevc, 'vlari': arivc}}, f, indent=2, ensureascii=False) print("💾 teamprototypes.json")

if viscache: visdir = os.path.join(OUTPUTDIR, 'debugframes', 'teamsplit') os.makedirs(visdir, existok=True) import matplotlib.pyplot as plt fidssorted = sorted(viscache.keys()) ncols = 4 nrows = int(np.ceil(len(fidssorted) / ncols)) fig, axes = plt.subplots(nrows, ncols, figsize=(6 ncols, 3.5 nrows)) axes = np.array(axes).ravel() for axi, fid in enumerate(fidssorted): ax = axes[axi] vis = viscache[fid].copy() for i in range(nobs): if obsfid[i] != fid: continue t = teamfull[i] col = TEAMBGR.get(t, (128, 128, 128)) x1, y1, x2, y2 = map(int, obsbbox[i]) cv2.rectangle(vis, (x1, y1), (x2, y2), col, 2) lab = TEAMNAMES.get(t, '?') if distfull[i] is not None: lab += f" {distfull[i]:.1f}" cv2.putText(vis, lab, (x1, max(12, y1 - 6)), cv2.FONTHERSHEYSIMPLEX, 0.5, col, 2, cv2.LINEAA) outpath = os.path.join(visdir, f"team{int(fid):06d}.jpg") cv2.imwrite(outpath, vis, [int(cv2.IMWRITEJPEGQUALITY), 88]) axes[axi].imshow(cv2.cvtColor(vis, cv2.COLORBGR2RGB)) axes[axi].settitle(f"frame {fid}", fontsize=10) axes[axi].axis('off') for axi in range(len(fidssorted), len(axes)): axes[axi].axis('off') plt.suptitle(f"v29: [{PARTMODE}] | порог {GRAYTHRESH:.2f} ({GRAYSRC}) | " f"split4={'on' if USESPLIT4 else 'off'} | refine={'off' if USESPLIT4 else 'on'}") plt.tightlayout() plt.show() print(f"🖼️ Визуализация: {visdir}")

percounts = defaultdict(lambda: [0, 0, 0]) for i in range(nobs): if not INCLUDEMASK[i]: continue percounts[obsfid[i]][teamfull[i] if teamfull[i] in (0, 1) else 2] += 1 avg0 = np.mean([c[0] for c in percounts.values()]) if percounts else 0.0 avg1 = np.mean([c[1] for c in percounts.values()]) if percounts else 0.0 avg2 = np.mean([c[2] for c in percounts.values()]) if per_counts else 0.0

print() print("📊 ИТОГ v29 (команда: 4-модель; серые: 2-шкала):") print(f" Кадров в разметке: {len(framesout)} | детекций: {int(INCLUDEMASK.sum())} | " f"{TEAMNAMES[0]}: {n0} | {TEAMNAMES[1]}: {n1} | серые: {n2}") if nexcluded: print(f" ⏭️ Калибровочные кадры исключены ({nexcluded} наблюдений)") print(f" В среднем на кадр: A={avg0:.1f}, B={avg1:.1f}, серые={avg2:.1f}") if ILLUMNORM and shiftnorms: print(f" 🌿 Нормализация освещения: p50={np.percentile(shiftnorms, 50):.1f}, " f"p95={np.percentile(shiftnorms, 95):.1f} LAB-ед.") print(f" 🌗 4-модель: {'ВКЛ, флипов команды: %d (%.1f%%)' % (nflips, 100.0*nflips/max(1,nvalid)) if USESPLIT4 else 'выкл/откат'}" f" | REFINE: {'выкл' if USESPLIT4 else 'вкл'}") if SMOOTHCOLOR: print(f" EMA-сглажено: {nsmoothed}/{nsmoothcandidates} " f"(IoU≥{SMOOTHIOU:.2f}, α={SMOOTHALPHA:.2f})") print(f" Части: [{PARTMODE}] | прототипы: {protosrc} | " f"порог серых: {GRAYTHRESH:.2f} ({GRAYSRC}) | ambiv={AMBIVFRAC}") if AUTOTUNE and AUTOINFO: print(" 🤖 Авто-параметры:") for k, v in AUTOINFO.items(): print(f" {k}: {v}") if agreevc is not None: print(f" Валидация VL: agree={agreevc:.2f}, ARI={ari_vc:.2f}") if suspect: print(f" ⚠️ Пограничных наблюдений (0.8–1.0 порога): {suspect}")

if callable(globals().get('freememory')): freememory() print("\n✅ Ячейка 22 v29 готова. Перезапустите 27 v3 для рендера видео.")

@title 23. Мяч: YOLO (+ опциональный TrackNet fusion) и кэш кандидатов [исправлено противоречие флага]

import os import json import numpy as np import time from tqdm.notebook import tqdm

=====================================================

Флаг TrackNet: False = только YOLO-ball, ONNX не требуется

=====================================================

USETRACKNET = bool(globals().get('USETRACKNET', False))

if USE_TRACKNET:

assert 'TrackNetBall' in globals(), "❌ TrackNetBall не определён (ячейка 2)."

assert os.path.exists(TRACKNETONNX), f"❌ TrackNet ONNX не найден: {TRACKNETONNX}"

tracknet = TrackNetBall(TRACKNET_ONNX)

print(f"✅ TrackNet загружен: {TRACKNET_ONNX}")

else:

tracknet = None

print("⏭️ TrackNet отключён (только YOLO-ball)")

Пока TrackNet не используем

tracknet = None print("⏭️ TrackNet отключён (только YOLO-ball)")

=====================================================

1. Проверка зависимостей

=====================================================

for v in ['getdetectionindices', 'DETCLASSID', 'DETCONF', 'DETCX', 'DETCY', 'CLSBALL', 'itervideoframes', 'CACHEDIR', 'TRACKINGFRAMEIDS', 'VIDEOPATH', 'OUTPUT_DIR']: assert v in globals(), f"❌ Не найдено: {v}. Проверьте ячейки 2, 5, 6, 7."

=====================================================

2. Параметры fusion

=====================================================

FUSIONDISTPX = float(globals().get('BALLFUSIONDISTPX', 40.0)) TRACKNETCONF = float(globals().get('TRACKNETCONF', 0.7)) YOLOMINCONF = float(globals().get('BALLYOLOMINCONF', 0.30))

BALLCACHE = os.path.join(CACHEDIR, 'balldetections.npz') BALLMETAPATH = os.path.join(OUTPUTDIR, 'balldetectionsmeta.json') USEBALLCACHE = bool(globals().get('USEBALLCACHE', True))

print(f" FUSIONDISTPX={FUSIONDISTPX}, TRACKNETCONF={TRACKNETCONF}, YOLOMINCONF={YOLOMINCONF}")

=====================================================

3. Проверка кэша

=====================================================

if USEBALLCACHE and os.path.exists(BALLCACHE): with np.load(BALLCACHE) as data: cachefids = data['frameid'].tolist() if set(cachefids) == set(TRACKINGFRAMEIDS): print(f"📦 Кэш мяча актуален: {BALLCACHE} ({len(cachefids)} кадров). Пропускаем пересчёт.") else: print("⚠️ Кэш не соответствует TRACKINGFRAMEIDS, будет пересоздан.") USEBALLCACHE = False else: if not os.path.exists(BALLCACHE): print(f"📦 Кэш мяча не найден: {BALLCACHE}. Будет создан заново.") USEBALL_CACHE = False

=====================================================

4. Сбор кандидатов

=====================================================

if not USEBALLCACHE: frameids, cxlist, cylist, conflist, sourcelist = [], [], [], [], [] t0 = time.perfcounter()

yoloballbyframe = {} for fid in TRACKINGFRAMEIDS: idxs = getdetectionindices(fid) if len(idxs) == 0: continue m = (DETCLASSID[idxs] == CLSBALL) & (DETCONF[idxs] >= YOLOMINCONF) idxsball = idxs[m] if len(idxsball) > 0: yoloballbyframe[fid] = idxs_ball

targetids = set(TRACKINGFRAMEIDS) nprocessed = 0

for fid, frame in tqdm(itervideoframes(), total=len(TRACKINGFRAMEIDS), desc="Ball candidates"): if fid not in target_ids: continue

if USETRACKNET and tracknet is not None: tnxy = tracknet(frame) tnx = float(tnxy[0]) if tnxy is not None else None tny = float(tnxy[1]) if tnxy is not None else None else: tnx, tny = None, None

yoloidxs = yoloballbyframe.get(fid, np.array([], dtype=np.int32)) if len(yoloidxs) > 0: yolox = DETCX[yoloidxs].tolist() yoloy = DETCY[yoloidxs].tolist() yoloc = DETCONF[yoloidxs].tolist() else: yolox, yoloy, yolo_c = [], [], []

added = 0 if tnx is not None and len(yolox) > 0: bestd, bestj = 1e9, -1 for j in range(len(yolox)): d = np.hypot(tnx - yolox[j], tny - yoloy[j]) if d < bestd: bestd, bestj = d, j if bestd <= FUSIONDISTPX and bestj >= 0: frameids.append(int(fid)); cxlist.append(float(yolox[bestj])) cylist.append(float(yoloy[bestj])) conflist.append(float(max(yoloc[bestj], TRACKNETCONF))); sourcelist.append(2) added += 1 for j in range(len(yolox)): if j == bestj: continue if np.hypot(tnx - yolox[j], tny - yoloy[j]) > FUSIONDISTPX * 2: frameids.append(int(fid)); cxlist.append(float(yolox[j])) cylist.append(float(yoloy[j])); conflist.append(float(yoloc[j])) sourcelist.append(0); added += 1 else: frameids.append(int(fid)); cxlist.append(float(tnx)); cylist.append(float(tny)) conflist.append(float(TRACKNETCONF)); sourcelist.append(1); added += 1 for j in range(len(yolox)): frameids.append(int(fid)); cxlist.append(float(yolox[j])) cylist.append(float(yoloy[j])); conflist.append(float(yoloc[j])) sourcelist.append(0); added += 1 elif tnx is not None: frameids.append(int(fid)); cxlist.append(float(tnx)); cylist.append(float(tny)) conflist.append(float(TRACKNETCONF)); sourcelist.append(1); added += 1 elif len(yolox) > 0: for j in range(len(yolox)): frameids.append(int(fid)); cxlist.append(float(yolox[j])) cylist.append(float(yoloy[j])); conflist.append(float(yoloc[j])) sourcelist.append(0); added += 1 n_processed += 1

elapsed = time.perf_counter() - t0

np.savezcompressed( BALLCACHE, frameid=np.array(frameids, dtype=np.int32), cx=np.array(cxlist, dtype=np.float32), cy=np.array(cylist, dtype=np.float32), conf=np.array(conflist, dtype=np.float32), source=np.array(sourcelist, dtype=np.int8), ) meta = { 'videopath': VIDEOPATH, 'numframes': int(nprocessed), 'numcandidates': int(len(frameids)), 'fusiondistpx': float(FUSIONDISTPX), 'tracknetconf': float(TRACKNETCONF), 'yolominconf': float(YOLOMINCONF), 'usetracknet': bool(USETRACKNET), 'elapsedseconds': float(elapsed), 'createdunixtime': time.time(), } with open(BALLMETAPATH, 'w', encoding='utf-8') as f: json.dump(meta, f, indent=2, ensureascii=False) print(f"✅ Кэш мяча сохранён: {BALLCACHE}") print(f"💾 Meta: {BALLMETA_PATH}")

=====================================================

5. Загрузка и статистика

=====================================================

with np.load(BALLCACHE) as data: BALLFRAMEID = data['frameid'].astype(np.int32) BALLCX = data['cx'].astype(np.float32) BALLCY = data['cy'].astype(np.float32) BALLCONF = data['conf'].astype(np.float32) BALLSOURCE = data['source'].astype(np.int8)

SOURCENAMES = {0: 'yolo', 1: 'tracknet', 2: 'fused'} frameswithball = len(np.unique(BALLFRAMEID)) framestotal = len(TRACKINGFRAMEIDS) print() print("📊 Статистика кандидатов мяча:") print(f" Кандидатов всего: {len(BALLFRAMEID)}") print(f" Кадров с кандидатами: {frameswithball}/{framestotal} " f"({100*frameswithball/max(1, framestotal):.1f}%)") for srcid, name in SOURCENAMES.items(): print(f" {name}: {int(np.sum(BALLSOURCE == srcid))}") print(f" Среднее conf: {BALLCONF.mean():.3f}") if callable(globals().get('freememory')): free_memory() print() print("✅ Ячейка 23 готова. Следующий шаг — ячейка 24.")

@title 24. Kalman-фильтр мяча + трекер + проекция на плоскость

import os import json import numpy as np import cv2 import matplotlib.pyplot as plt from collections import defaultdict

=====================================================

1. Проверка зависимостей

=====================================================

for v in ['BALLCACHE', 'getH', 'TRACKINGFRAMEIDS', 'VIDEOFPS', 'OUTPUTDIR']: assert v in globals(), f"❌ Не найдено: {v}. Проверьте ячейки 23, 13, 7."

assert os.path.exists(BALLCACHE), f"❌ Файл не найден: {BALLCACHE}"

=====================================================

2. Загрузка кандидатов мяча

=====================================================

with np.load(BALLCACHE) as data: BALLFRAMEID = data['frameid'].astype(np.int32) BALLCX = data['cx'].astype(np.float32) BALLCY = data['cy'].astype(np.float32) BALL_CONF = data['conf'].astype(np.float32)

print(f"📦 Загружено кандидатов: {len(BALLFRAMEID)}")

Группировка по кадрам

ballbyframe = defaultdict(list) for i in range(len(BALLFRAMEID)): fid = int(BALLFRAMEID[i]) ballbyframe[fid].append({ 'cx': float(BALLCX[i]), 'cy': float(BALLCY[i]), 'conf': float(BALL_CONF[i]) })

=====================================================

3. Kalman-фильтр для мяча в кадре (2D)

=====================================================

class BallKalman: """ Kalman-фильтр для мяча в image-space. Состояние: [x, y, vx, vy] """ def _init_(self, x, y): self.s = np.array([x, y, 0.0, 0.0], dtype=np.float64) self.P = np.diag([25.0, 25.0, 100.0, 100.0]).astype(np.float64)

self.H = np.array([[1, 0, 0, 0], [0, 1, 0, 0]], dtype=np.float64) self.R = np.eye(2) * 25.0 # шум измерения (px)

# Процесс: white noise acceleration self.q = 10000.0 # шум ускорения (px/s^2)

def predict(self, dt): dt = max(dt, 1e-3) F = np.array([[1, 0, dt, 0], [0, 1, 0, dt], [0, 0, 1, 0], [0, 0, 0, 1]], dtype=np.float64)

Q = self.q np.array([ [dt4/4, 0, dt3/2, 0], [0, dt4/4, 0, dt3/2], [dt3/2, 0, dt2, 0], [0, dt3/2, 0, dt*2] ], dtype=np.float64)

self.s = F @ self.s self.P = F @ self.P @ F.T + Q

def update(self, x, y): z = np.array([x, y], dtype=np.float64) y_innov = z - self.H @ self.s S = self.H @ self.P @ self.H.T + self.R K = self.P @ self.H.T @ np.linalg.inv(S)

self.s = self.s + K @ yinnov IKH = np.eye(4) - K @ self.H self.P = IKH @ self.P @ IKH.T + K @ self.R @ K.T

def position(self): return float(self.s[0]), float(self.s[1])

def velocity(self): return float(self.s[2]), float(self.s[3])

def mahalanobis(self, x, y): z = np.array([x, y], dtype=np.float64) yinnov = z - self.H @ self.s Sinv = np.linalg.inv(self.H @ self.P @ self.H.T + self.R) d2 = float(yinnov @ Sinv @ y_innov) return float(np.sqrt(max(d2, 0.0)))

=====================================================

4. Трекер мяча (связывание кандидатов через Kalman)

=====================================================

BALLTRACKMAXDISTPX = 80.0 BALLTRACKMIN_CONF = 0.4

balltrack = [] kf = None prevfid = None

for fid in TRACKINGFRAMEIDS: cands = ballbyframe.get(fid, []) cands = [c for c in cands if c['conf'] >= BALLTRACKMIN_CONF]

dt = (fid - prevfid) / VIDEOFPS if prevfid is not None else 1.0 / VIDEOFPS

if not cands: if kf is not None: kf.predict(dt) x, y = kf.position() balltrack.append({ 'frameid': fid, 'cx': x, 'cy': y, 'vx': kf.s[2], 'vy': kf.s[3], 'status': 'predicted' }) prev_fid = fid continue

if kf is None: best = max(cands, key=lambda c: c['conf']) kf = BallKalman(best['cx'], best['cy']) status = 'init' else: kf.predict(dt) bestidx, bestdist = -1, 1e9 for i, c in enumerate(cands): d = kf.mahalanobis(c['cx'], c['cy']) if d < bestdist: bestdist, bestidx = d, i if bestidx >= 0 and bestdist < BALLTRACKMAXDISTPX: kf.update(cands[bestidx]['cx'], cands[best_idx]['cy']) status = 'matched' else: status = 'predicted'

x, y = kf.position() balltrack.append({ 'frameid': fid, 'cx': x, 'cy': y, 'vx': kf.s[2], 'vy': kf.s[3], 'status': status }) prev_fid = fid

=====================================================

5. Статистика трека мяча

=====================================================

nmatched = sum(1 for r in balltrack if r['status'] == 'matched') npredicted = sum(1 for r in balltrack if r['status'] == 'predicted') ntotal = len(balltrack)

print() print("📊 Статистика трека мяча:") print(f" Всего кадров: {ntotal}") print(f" Matched (обновлён детекцией): {nmatched} ({100n_matched/max(1,n_total):.1f}%)") print(f" Predicted (только Kalman): {n_predicted} ({100npredicted/max(1,ntotal):.1f}%)")

=====================================================

6. Проекция мяча на плоскость поля

=====================================================

ballpitchtrack = []

for rec in balltrack: fid = rec['frameid'] cx, cy = rec['cx'], rec['cy']

H, Hinv, valid, src = getH(fid)

if not valid: continue

# Проекция image -> pitch p = H @ np.array([cx, cy, 1.0], dtype=np.float64) if abs(p[2]) < 1e-9: continue

pitchx = p[0] / p[2] pitchy = p[1] / p[2]

# Фильтрация за пределами поля if not (-5.0 <= pitchx <= 110.0 and -5.0 <= pitchy <= 73.0): continue

# Проекция скорости (упрощённо: через конечные разности) ballpitchtrack.append({ 'frameid': fid, 'cximage': cx, 'cyimage': cy, 'pitchx': pitchx, 'pitchy': pitch_y, 'status': rec['status'] })

print(f" С валидной проекцией на поле: {len(ballpitchtrack)}")

=====================================================

7. Сохранение трека мяча

=====================================================

BALLTRACKPATH = os.path.join(OUTPUTDIR, 'balltrack.json')

ballpayload = { 'meta': { 'totalrecords': len(balltrack), 'matched': nmatched, 'predicted': npredicted, 'withpitchprojection': len(ballpitchtrack) }, 'track': ballpitch_track }

with open(BALLTRACKPATH, 'w', encoding='utf-8') as f: json.dump(ballpayload, f, indent=2, ensureascii=False)

print(f"💾 Сохранено: {BALLTRACKPATH}")

=====================================================

8. Визуализация трека мяча на миникапе

=====================================================

def drawpitchlines(ax, color='white', lw=1.0): for (i, j) in PITCHCONFIG.edges: p1 = PITCHVERTICESM[i - 1] p2 = PITCHVERTICES_M[j - 1] ax.plot([p1[0], p2[0]], [p1[1], p2[1]], color=color, lw=lw)

fig, ax = plt.subplots(figsize=(14, 8)) ax.setfacecolor('#0a5c0a') drawpitch_lines(ax, color='white', lw=1.0)

Центральная линия и круг

ax.axvline(52.5, color='white', lw=1.0) ax.add_patch(plt.Circle((52.5, 34.0), 9.15, fill=False, color='white', lw=1.0))

Трек мяча

pitchxs = [r['pitchx'] for r in ballpitchtrack] pitchys = [r['pitchy'] for r in ballpitchtrack]

ax.plot(pitchxs, pitchys, 'o-', color='yellow', markersize=3, lw=1.5, alpha=0.7, label='Ball track')

ax.setxlim(-5, 110) ax.setylim(73, -5) ax.setaspect('equal') ax.settitle(f"Трек мяча на плоскости ({len(ballpitchtrack)} кадров)") ax.legend() ax.axis('off') plt.tight_layout() plt.show()

=====================================================

9. Скорость мяча (упрощённый расчёт)

=====================================================

if len(ballpitchtrack) > 1: speeds = [] for i in range(1, len(ballpitchtrack)): if ballpitchtrack[i]['frameid'] - ballpitchtrack[i-1]['frameid'] == 1: dx = ballpitchtrack[i]['pitchx'] - ballpitchtrack[i-1]['pitchx'] dy = ballpitchtrack[i]['pitchy'] - ballpitchtrack[i-1]['pitchy'] dist = np.sqrt(dx2 + dy2) speed = dist * VIDEO_FPS # м/с speeds.append(speed)

speeds = np.array(speeds) print() print("📈 Скорость мяча:") print(f" Средняя: {speeds.mean():.2f} м/с") print(f" P95: {np.percentile(speeds, 95):.2f} м/с") print(f" Max: {speeds.max():.2f} м/с")

if callable(globals().get('freememory')): freememory()

print() print("✅ Ячейка 24 готова: ballpitchtrack сохранён.") print(" Следующий шаг — ячейка 25: possession (владение мячом).")

@title 25. Possession: владение мячом по близости на плоскости

import os import json import numpy as np import cv2 import matplotlib.pyplot as plt from collections import defaultdict, Counter

=====================================================

1. Проверка зависимостей

=====================================================

for v in ['BASELINETRACKS', 'getH', 'PITCHCONFIG', 'PITCHVERTICESM', 'VIDEOFPS', 'OUTPUTDIR', 'TRACKINGFRAME_IDS']: assert v in globals(), f"❌ Не найдено: {v}"

POSSESSDISTM = float(globals().get('POSSESSIONDISTANCEM', 2.0)) POSSESSMINVOTES = int(globals().get('POSSESSIONFRAMESTHRESHOLD', 3))

POSSESSIONPATH = os.path.join(OUTPUTDIR, 'possession.json')

=====================================================

2. Загрузка трека мяча

=====================================================

if 'ballpitchtrack' in globals() and len(ballpitchtrack) > 0: balltrack = ballpitchtrack else: with open(os.path.join(OUTPUTDIR, 'balltrack.json'), 'r', encoding='utf-8') as f: balltrack = json.load(f)['track']

ballbyframe = {int(r['frameid']): r for r in balltrack} print(f"📦 Кадров с мячом: {len(ballbyframe)}")

=====================================================

3. Позиции игроков на плоскости по кадрам

=====================================================

playersbyframe = defaultdict(list) for r in BASELINETRACKS: fid = int(r['frameid']) H, , valid, = getH(fid) if not valid: continue p = H @ np.array([r['footimage'][0], r['footimage'][1], 1.0], dtype=np.float64) if abs(p[2]) < 1e-9: continue playersbyframe[fid].append( (int(r['trackid']), p[0] / p[2], p[1] / p[2], int(r['class_id'])) )

print(f"📦 Кадров с игроками: {len(playersbyframe)}")

=====================================================

4. Сырой владелец: ближайший игрок в радиусе POSSESSDISTM

=====================================================

rawowner = {} rawdist = {}

for fid, b in ballbyframe.items(): bx, by = b['pitchx'], b['pitchy'] besttid, bestd = None, 1e9 for (tid, px, py, cls) in playersbyframe.get(fid, []): d = np.hypot(px - bx, py - by) if d < bestd: besttid, best_d = tid, d

if besttid is not None and bestd <= POSSESSDISTM: rawowner[fid] = besttid rawdist[fid] = float(bestd) else: rawowner[fid] = None rawdist[fid] = float(bestd) if besttid is not None else None

=====================================================

5. Сглаживание: majority vote в окне 5 кадров (порог 3)

=====================================================

fidssorted = sorted(ballbyframe.keys()) ownersmooth = {}

for i, fid in enumerate(fidssorted): win = fidssorted[max(0, i - 2): i + 3] votes = [rawowner[f] for f in win if rawowner.get(f) is not None] if votes: tid, n = Counter(votes).mostcommon(1)[0] ownersmooth[fid] = tid if n >= POSSESSMINVOTES else None else: owner_smooth[fid] = None

=====================================================

6. Статистика владения

=====================================================

frameswithowner = sum(1 for fid in fidssorted if ownersmooth[fid] is not None)

ownercounts = Counter( ownersmooth[fid] for fid in fidssorted if ownersmooth[fid] is not None )

Смены владения (границы событий)

transitions = 0 prevowner = 'none' for fid in fidssorted: cur = ownersmooth[fid] curkey = 'none' if cur is None else cur if curkey != prevowner and curkey != 'none': transitions += 1 prevowner = curkey if curkey != 'none' else prev_owner

print() print("📊 Статистика владения:") print(f" Кадров всего: {len(fidssorted)}") print(f" Кадров с владельцем: {frameswithowner} " f"({100 * frameswithowner / max(1, len(fidssorted)):.1f}%)") print(f" Смен владения (событий): {transitions}") print() print(f"{'ID':>4} {'cls':>3} {'frames':>6} {'sec':>6} {'share':>7}") print("-" * 35)

summary = [] for tid, cnt in ownercounts.mostcommon(): cls = 1 if tid in [t['trackid'] for t in BASELINETRACKS if t['classid'] == 1] else 2 sec = cnt / VIDEOFPS share = 100 * cnt / max(1, frameswithowner) summary.append({ 'trackid': int(tid), 'classid': int(cls), 'possessionframes': int(cnt), 'possessionsec': round(sec, 2), 'share_pct': round(share, 1) }) print(f"{tid:>4} {cls:>3} {cnt:>6} {sec:>6.1f} {share:>6.1f}%")

=====================================================

7. Сохранение

=====================================================

payload = { 'meta': { 'possessdistm': POSSESSDISTM, 'minvotes': POSSESSMINVOTES, 'framestotal': len(fidssorted), 'frameswithowner': frameswithowner, 'transitions': transitions }, 'perframe': [ { 'frameid': int(fid), 'ballpitch': [ballbyframe[fid]['pitchx'], ballbyframe[fid]['pitchy']], 'ownertrackid': ownersmooth[fid], 'disttonearestm': rawdist[fid] } for fid in fidssorted ], 'summary': summary }

with open(POSSESSIONPATH, 'w', encoding='utf-8') as f: json.dump(payload, f, ensureascii=False, separators=(',', ':'))

print(f"\n💾 Сохранено: {POSSESSION_PATH}")

=====================================================

8. Визуализация: миникап с мячом и владельцем

=====================================================

S = 8 M = 5 MW = int((105 + 2 M) S) MH = int((68 + 2 M) S)

def to_px(x, y): return int((x + M) S), int((y + M) S)

canvas = np.zeros((MH, MW, 3), dtype=np.uint8) canvas[:] = (12, 62, 12) for (i, j) in PITCHCONFIG.edges: p1 = topx(PITCH_VERTICES_M[i - 1]) p2 = to_px(PITCHVERTICESM[j - 1]) cv2.line(canvas, p1, p2, (230, 230, 230), 2, cv2.LINEAA) cv2.circle(canvas, topx(52.5, 34.0), int(round(9.15 * S)), (230, 230, 230), 2, cv2.LINE_AA)

visfids = [f for f in fidssorted if ownersmooth[f] is not None] pick = [visfids[k] for k in np.linspace(0, len(visfids) - 1, 6).astype(int)] if visfids else []

fig, axes = plt.subplots(2, 3, figsize=(20, 10)) axes = axes.ravel()

for axi, fid in enumerate(pick): ax = axes[axi] mini = canvas.copy()

# игроки for (tid, px, py, cls) in playersbyframe.get(fid, []): c = topx(px, py) cv2.circle(mini, c, 5, (180, 180, 180), -1, cv2.LINEAA)

# владелец — кольцо owner = ownersmooth[fid] if owner is not None: for (tid, px, py, cls) in playersbyframe.get(fid, []): if tid == owner: c = topx(px, py) cv2.circle(mini, c, 8, (0, 0, 255), 2, cv2.LINEAA) cv2.putText(mini, str(tid), (c[0] + 9, c[1] - 9), cv2.FONTHERSHEYSIMPLEX, 0.5, (255, 255, 255), 1, cv2.LINEAA)

# мяч b = ballbyframe[fid] bc = topx(b['pitchx'], b['pitchy']) cv2.circle(mini, bc, 6, (0, 255, 255), -1, cv2.LINEAA)

ax.imshow(cv2.cvtColor(mini, cv2.COLORBGR2RGB)) ax.settitle(f"frame {fid} | owner: {owner}") ax.axis('off')

plt.suptitle("Possession: мяч (жёлтый) + владелец (красное кольцо)") plt.tight_layout() plt.show()

if callable(globals().get('freememory')): freememory()

print() print("✅ Ячейка 25 готова. Следующий шаг — ячейка 26: кластеризация команд по цвету формы.")

@title 27 v3. Рендер полного видео: покадровое разделение по командам (без треков)

Вход: frameteamassignment.json (ячейка 22 v24) + кэш детекций (ячейки 6-7).

Выход: finaloverlay.mp4, finalminimap.mp4, final_combined.mp4.

#

ВАЖНО: координаты (гомография) используются ТОЛЬКО для отрисовки миникапы,

не для распределения по командам (оно уже сделано в 22 v24 по цвету формы).

import os, json import numpy as np import cv2 from collections import defaultdict

=====================================================

1. Проверки и данные

=====================================================

for v in ['OUTPUTDIR', 'VIDEOFPS', 'TRACKINGFRAMEIDS', 'itervideoframes']: assert v in globals(), f"❌ Не найдено: {v}. Выполните ячейки 1-13." for v in ['DETX1', 'DETY1', 'DETX2', 'DETY2']: assert v in globals(), f"❌ Не найдено: {v}. Выполните ячейку 7 (кэш детекций)." assert callable(globals().get('getdetectionindices')), "❌ Нет getdetectionindices (ячейка 7)."

FTAPATH = os.path.join(OUTPUTDIR, 'frameteamassignment.json') if not os.path.exists(FTAPATH): old = os.path.join(OUTPUTDIR, 'teamassignment.json') if os.path.exists(old): raise RuntimeError("❌ Найден СТАРЫЙ teamassignment.json, но нет " "frameteamassignment.json. Перезапустите ячейку 22 v24.") raise RuntimeError("❌ frameteamassignment.json не найден. Сначала ячейка 22 v24.")

with open(FTAPATH, 'r', encoding='utf-8') as f: fta = json.load(f) META = fta.get('meta', {}) framesmap = {int(k): v for k, v in fta.get('frames', {}).items()} assert framesmap, "❌ Пустая разметка в frameteam_assignment.json"

TEAMNAMES = {int(k): v for k, v in META.get('teamnames', {0: 'teamA', 1: 'teamB'}).items()} un = [k for k, v in TEAMNAMES.items() if v == 'unassigned'] UNASSIGNED = int(un[0]) if un else 2 SHORT = {0: 'A', 1: 'B', UNASSIGNED: 'X'}

TEAMBGR = {0: (0, 140, 255), 1: (255, 80, 80), UNASSIGNED: (128, 128, 128)} TEAMDOT = {0: (0, 165, 255), 1: (255, 90, 90), UNASSIGNED: (128, 128, 128)} GRAY = (128, 128, 128)

--- опции ---

SHOWDIST = True #False # True -> подпись вида "A 3.2" (расстояние до прототипа) SHOWREFEREE = False # True -> рисовать referee тонкой серой рамкой H_TARGET = 720

HAVEH = callable(globals().get('getH')) HAVEFOOT = 'DETFOOTX' in globals() and 'DETFOOTY' in globals() if not HAVEH: print("⚠️ get_H недоступен — миникапа без позиций игроков (только поле).")

--- мяч (опционально) ---

ballbyfid = {} BALLPATH = os.path.join(OUTPUTDIR, 'balltrack.json') if os.path.exists(BALLPATH): with open(BALLPATH, 'r', encoding='utf-8') as f: ballbyfid = {int(r['frameid']): r for r in json.load(f).get('track', [])} print(f"⚽ Мяч: {len(ballbyfid)} кадров") else: print("ℹ️ ball_track.json не найден — мяч не отображается.")

nrectotal = sum(len(v) for v in framesmap.values()) print(f"📥 Разметка: {len(framesmap)} кадров, {nrectotal} детекций-игроков | " f"команды: {TEAMNAMES} | прототипы: {META.get('protosrc', '?')}")

=====================================================

2. Минимап-полотно

=====================================================

S, M = 8, 5 MW, MH = int((105 + 2 M) S), int((68 + 2 M) S) def to_px(x, y): return int((x + M) S), int((y + M) S)

pitchcanvas = np.zeros((MH, MW, 3), dtype=np.uint8) pitchcanvas[:] = (12, 62, 12) for (a, b) in PITCHCONFIG.edges: cv2.line(pitchcanvas, topx(*PITCHVERTICESM[a - 1]), topx(PITCH_VERTICES_M[b - 1]), (230, 230, 230), 2, cv2.LINE_AA) cv2.circle(pitch_canvas, to_px(52.5, 34.0), int(round(9.15 S)), (230, 230, 230), 2, cv2.LINE_AA)

=====================================================

3. Рендер: overlay + миникапа + combined

=====================================================

OVERLAYVIDEO = os.path.join(OUTPUTDIR, 'finaloverlay.mp4') MINIMAPVIDEO = os.path.join(OUTPUTDIR, 'finalminimap.mp4') COMBINEDVIDEO = os.path.join(OUTPUTDIR, 'finalcombined.mp4') fourcc = cv2.VideoWriterfourcc(*'mp4v')

BALLJUMPM = 6.0 balltrail = [] wov = wmm = wcb = None nwritten = 0 statscnt = {0: 0, 1: 0, UNASSIGNED: 0}

for fid, frame in itervideoframes(): if fid not in framesmap: # v26: калибровочные кадры исключены из разметки continue vis = frame.copy() mini = pitchcanvas.copy()

H = None if HAVEH: H, Hinv, validh, = getH(fid) if validh: H = H

# --- игроки: bbox + буква команды (цвет формы из 22 v24) --- for rec in framesmap.get(fid, []): g = int(rec['gidx']) t = int(rec['team']) col = TEAMBGR.get(t, GRAY) x1, y1 = int(DETX1[g]), int(DETY1[g]) x2, y2 = int(DETX2[g]), int(DETY2[g]) cv2.rectangle(vis, (x1, y1), (x2, y2), col, 2)

label = SHORT.get(t, '?') if SHOWDIST and rec.get('dist') is not None: label += f" {rec['dist']:.1f}" (tw, th), = cv2.getTextSize(label, cv2.FONTHERSHEYSIMPLEX, 0.6, 2) cv2.rectangle(vis, (x1, y1 - th - 10), (x1 + tw + 4, y1), col, -1) cv2.putText(vis, label, (x1 + 2, y1 - 6), cv2.FONTHERSHEYSIMPLEX, 0.6, (0, 0, 0), 2, cv2.LINEAA) statscnt[t if t in (0, 1) else UNASSIGNED] += 1

# --- миникапа: точка игрока (foot-проекция; только отображение) --- if H is not None and HAVEFOOT: fx, fy = float(DETFOOTX[g]), float(DETFOOTY[g]) p = H @ np.array([fx, fy, 1.0], dtype=np.float64) if abs(p[2]) > 1e-9: px, py = p[0] / p[2], p[1] / p[2] if -M <= px <= 105 + M and -M <= py <= 68 + M: cx, cy = topx(px, py) cv2.circle(mini, (cx, cy), 4, TEAMDOT.get(t, GRAY), -1, cv2.LINEAA)

# --- referee (опционально, тонкая рамка, вне разметки команд) --- if SHOWREFEREE and 'DETCLASSID' in globals() and 'CLSREF' in globals(): for g in getdetectionindices(fid): if int(DETCLASSID[g]) == int(CLSREF): x1, y1 = int(DETX1[g]), int(DETY1[g]) x2, y2 = int(DETX2[g]), int(DET_Y2[g]) cv2.rectangle(vis, (x1, y1), (x2, y2), GRAY, 1)

# --- мяч --- b = ballbyfid.get(fid) if b is not None: bx, by = b.get('cximage', b.get('cx')), b.get('cyimage', b.get('cy')) if bx is not None: bx, by = int(float(bx)), int(float(by)) cv2.circle(vis, (bx, by), 5, (0, 255, 255), -1, cv2.LINEAA) if H is not None: px, py = b.get('pitchx'), b.get('pitchy') if px is None and bx is not None: p = H @ np.array([bx, by, 1.0], dtype=np.float64) if abs(p[2]) > 1e-9: px, py = p[0] / p[2], p[1] / p[2] if px is not None and -M <= px <= 105 + M and -M <= py <= 68 + M: if balltrail and np.hypot(px - balltrail[-1][0], py - balltrail[-1][1]) > BALLJUMPM: balltrail = [] balltrail.append((px, py)) if len(balltrail) > 1: cv2.polylines(mini, [np.array([topx(*q) for q in balltrail], np.int32)], False, (0, 255, 255), 1, cv2.LINEAA) mx, my = topx(px, py) cv2.circle(mini, (mx, my), 3, (0, 255, 255), -1, cv2.LINEAA)

# --- запись --- ovs = cv2.resize(vis, (int(vis.shape[1] * HTARGET / vis.shape[0]), HTARGET)) mms = cv2.resize(mini, (int(mini.shape[1] * HTARGET / mini.shape[0]), HTARGET)) if wov is None: wov = cv2.VideoWriter(OVERLAYVIDEO, fourcc, VIDEOFPS, (ovs.shape[1], ovs.shape[0])) wmm = cv2.VideoWriter(MINIMAPVIDEO, fourcc, VIDEOFPS, (mms.shape[1], mms.shape[0])) wcb = cv2.VideoWriter(COMBINEDVIDEO, fourcc, VIDEOFPS, (ovs.shape[1] + mms.shape[1], HTARGET)) wov.write(ovs); wmm.write(mms) wcb.write(np.hstack([ovs, mms]))

nwritten += 1 if nwritten % 100 == 0: print(f" Отрендерено кадров: {n_written}")

if wov is not None: wov.release(); wmm.release(); wcb.release() for p in (OVERLAYVIDEO, MINIMAPVIDEO, COMBINED_VIDEO): print(f"💾 {p} ({os.path.getsize(p) / 1e6:.1f} MB)") else: print("⚠️ Ни одного кадра не записано — видео не созданы.")

=====================================================

4. Краткая статистика

=====================================================

n0, n1, n2 = statscnt[0], statscnt[1], statscnt[UNASSIGNED] avg = {k: v / max(1, nwritten) for k, v in statscnt.items()} print() print("📊 Рендер завершён:") print(f" Кадров: {nwritten} | детекций-игроков: {nrectotal}") print(f" {TEAMNAMES.get(0, 'A')}: {n0} ({avg[0]:.1f}/кадр) | " f"{TEAMNAMES.get(1, 'B')}: {n1} ({avg[1]:.1f}/кадр) | " f"серые: {n2} ({avg[UNASSIGNED]:.1f}/кадр)") if META.get('vlvalidation', {}).get('ari') is not None: vv = META['vlvalidation'] print(f" Валидация разметки (из 22): agree={vv['agree']:.2f}, ARI={vv['ari']:.2f}")

with open(os.path.join(OUTPUTDIR, 'renderstats.json'), 'w', encoding='utf-8') as f: json.dump({'frames': nwritten, 'detplayers': nrectotal, 'counts': {'teamA': n0, 'teamB': n1, 'unassigned': n2}, 'perframeavg': {'teamA': round(avg[0], 2), 'teamB': round(avg[1], 2), 'unassigned': round(avg[UNASSIGNED], 2)}}, f, indent=2, ensure_ascii=False)

print("\n✅ Ячейка 27 v3 готова: видео с покадровым разделением по командам.")

@title 28 v4. Appearance-кэш, pitch-проекции, IoU-рёбра и GK-примитивы (подготовка треков)

#

v4: [+] ВИЗУАЛИЗАЦИЯ: вратари на миникапах обведены красными кольцами (r=12, толщина 3)

поверх цветной точки GK100 (оранжевая) / GC (фиолетовая) — заметность.

v3: [+] GK_COLOR — пер-кадровый цветовой примитив GK (форма явно отличается от обеих

команд: dist > gray_thresh; судьи исключены автоматически — нет dist в FTA).

sidehint по половине поля (pitchx < 52.5 -> L). Это ГОЛОСА для трекового

решения (правило 5b в ячейке 30), НЕ самостоятельная метка GK.

[+] визуализация: фиолетовый GC L/R; в выбор кадров добавлен пример GC-L.

v2: [FIX] распаковка VL-кэша; AP_W (гейт G5b); дедуп VL; ранняя сводка цвета.

#

Вход: detections.npz (яч.6-7), frameteamassignment.json + team_prototypes.json (яч.22),

vlembeddingsv*.npz (яч.22), get_H (яч.13).

Выход: cache/appearancecache.npz, output/gkframe_primitives.json, визуализация.

Глобалы для ячейки 29: AP* массивы, APFRAMEROWS, APROW, VLPOS, IOUA/B/VAL,

GKPFRAMES, GK100EVENTS, GKCOLOREVENTS, GRAYTHRESH, UNASSIGNED, PROTO, FTAFRAMES.

import os, gc, glob, json, re, time import numpy as np import cv2 from collections import Counter from tqdm.notebook import tqdm import matplotlib.pyplot as plt

=====================================================

1. Проверки зависимостей и константы

=====================================================

for v in ['DETECTIONSCACHE', 'DETFRAMEID', 'DETCLASSID', 'DETX1', 'DETY1', 'DETX2', 'DETY2', 'DETCONF', 'DETFOOTX', 'DETFOOTY', 'TRACKINGFRAMEIDS', 'CACHEDIR', 'OUTPUTDIR', 'VIDEOPATH', 'CLSBALL', 'CLSGK', 'CLSPLAYER', 'CLSREF', 'VIDEOFPS', 'PITCHCONFIG', 'PITCHVERTICESM']: assert v in globals(), f"❌ Не найдено: {v}. Выполните ячейки 1-7 и 11." assert callable(globals().get('getH')), "❌ getH не найден — выполните ячейку 13." assert callable(globals().get('getdetectionindices')), "❌ getdetectionindices (ячейка 7)." assert callable(globals().get('itervideoframes')), "❌ itervideo_frames (ячейка 5)."

FTAPATH = os.path.join(OUTPUTDIR, 'frameteamassignment.json') PROTOPATH = os.path.join(OUTPUTDIR, 'teamprototypes.json') assert os.path.exists(FTAPATH), "❌ frameteamassignment.json не найден — выполните ячейку 22." assert os.path.exists(PROTOPATH), "❌ teamprototypes.json не найден — выполните ячейку 22."

APCACHEPATH = os.path.join(CACHEDIR, 'appearancecache.npz') GKPRIMPATH = os.path.join(OUTPUTDIR, 'gkframeprimitives.json') VISDIR = os.path.join(OUTPUTDIR, 'debugframes', 'cell28') os.makedirs(VISDIR, existok=True)

FW = int(globals().get('FRAMEW', 1280)) FH = int(globals().get('FRAMEH', 720)) PERSONCLASSES = (int(CLSGK), int(CLSPLAYER), int(CLSREF))

--- зоны GK (метры; допуск 1.0 м за линией ворот) ---

GAY = (24.84, 43.16) PAY = (13.84, 54.16) GAX = {'L': (-1.0, 5.5), 'R': (99.5, 106.0)} PAX = {'L': (-1.0, 16.5), 'R': (88.5, 106.0)} GK100CODE = {1: 'GAL', 2: 'GAR', 3: 'PAL', 4: 'PAR'} CENTERXM = 52.5 # граница половин поля для sidehint GKCOLOR IOUEDGE_MIN = 0.10

def pickuniform(lst, k): if k <= 0 or not lst: return [] if k >= len(lst): return list(lst) return [lst[i] for i in np.linspace(0, len(lst) - 1, k).astype(int)]

=====================================================

2. FTA + прототипы

=====================================================

with open(FTAPATH, 'r', encoding='utf-8') as f: FTA = json.load(f) FTAMETA = FTA.get('meta', {}) FTAFRAMES = {int(k): v for k, v in FTA.get('frames', {}).items()} assert FTAFRAMES, "❌ FTA без кадров." TEAMNAMES = {int(k): v for k, v in FTAMETA.get('teamnames', {0: 'teamA', 1: 'teamB'}).items()} un = [k for k, v in TEAMNAMES.items() if v == 'unassigned'] UNASSIGNED = int(un[0]) if _un else 2

teamofgidx, distofgidx = {}, {} for recs in FTAFRAMES.values(): for r in recs: teamofgidx[int(r['gidx'])] = int(r['team']) if r.get('dist') is not None: distof_gidx[int(r['gidx'])] = float(r['dist'])

with open(PROTOPATH, 'r', encoding='utf-8') as f: PROTO = json.load(f) BLOCKS = PROTO.get('blocks', []) GRAYTHRESH = float(PROTO.get('graythresh', FTAMETA.get('graythresh', 3.5))) TCHROMA = float(FTAMETA.get('Tchroma', 20.0)) TV = float(FTAMETA.get('TV', 140.0)) ILL = FTAMETA.get('illum', {}) GRASSREF = (np.asarray(ILL['grassref'], np.float32) if (ILL.get('enabled') and ILL.get('grassref')) else None) print(f"⚙️ graythresh={GRAYTHRESH:.2f} | Tchroma={TCHROMA:.1f} | TV={TV:.1f} | " f"GRASSREF={'есть' if GRASS_REF is not None else 'нет'} | blocks={len(BLOCKS)}")

=====================================================

3. Persons: все детекции player/GK/ref по кадрам

=====================================================

pg, pf, pc, pcf = [], [], [], [] px1, py1, px2, py2, pfx, pfy = [], [], [], [], [], [] APFRAMEROWS = {} for fid in TRACKINGFRAMEIDS: rows = [] for g in getdetectionindices(fid): if int(DETCLASSID[g]) not in PERSONCLASSES: continue rows.append(len(pg)) pg.append(int(g)); pf.append(int(fid)) pc.append(int(DETCLASSID[g])); pcf.append(float(DETCONF[g])) px1.append(float(DETX1[g])); py1.append(float(DETY1[g])) px2.append(float(DETX2[g])); py2.append(float(DETY2[g])) pfx.append(float(DETFOOTX[g])); pfy.append(float(DETFOOTY[g])) APFRAME_ROWS[int(fid)] = np.asarray(rows, dtype=np.int64)

APGIDX = np.asarray(pg, np.int64) APFID = np.asarray(pf, np.int32) APCLS = np.asarray(pc, np.int8) APCONF = np.asarray(pcf, np.float32) APX1 = np.asarray(px1, np.float32); APY1 = np.asarray(py1, np.float32) APX2 = np.asarray(px2, np.float32); APY2 = np.asarray(py2, np.float32) APFOOTX = np.asarray(pfx, np.float32); APFOOTY = np.asarray(pfy, np.float32) APH = APY2 - APY1 APW = np.maximum(APX2 - APX1, 0.0) # ширины для гейта G5b (ячейка 29) APROW = {int(g): i for i, g in enumerate(APGIDX)} NAP = len(APGIDX) assert N_AP > 0, "❌ Нет person-детекций в кэше."

team/dist из FTA (нет метки: судьи и калибровочные кадры -> -1 / NaN)

APTEAM = np.full(NAP, -1, np.int8) APDIST = np.full(NAP, np.nan, np.float32) for i, g in enumerate(APGIDX.tolist()): t = teamofgidx.get(g) if t is not None: APTEAM[i] = t d = distofgidx.get(g) if d is not None: AP_DIST[i] = d

защита: FTA от другого видео/сегмента?

if teamofgidx: inter = len(set(teamofgidx) & set(APROW)) if inter < 0.5 * len(teamofgidx): print(f"⚠️⚠️ FTA согласуется с кэшем детекций только на {inter}/{len(teamofgidx)} gidx — " f"возможно, разметка от другого видео/сегмента!") print(f"🧍 Persons: {NAP} ({NAP/len(TRACKINGFRAMEIDS):.1f}/кадр) | " f"referees: {int((APCLS == int(CLSREF)).sum())}")

=====================================================

4. Pitch-проекции (foot -> H -> метры)

=====================================================

APPX = np.full(NAP, np.nan, np.float32) APPY = np.full(NAP, np.nan, np.float32) APPROJ = np.zeros(NAP, bool) GETHSRC = None for fid in TRACKINGFRAMEIDS: H, Hinv, ok, src = getH(fid) if GETHSRC is None: GETHSRC = str(src) if not ok or H is None: continue rows = APFRAMEROWS[fid] if len(rows) == 0: continue pts = np.stack([APFOOTX[rows], APFOOTY[rows], np.ones(len(rows), np.float32)], axis=1).astype(np.float64) P = pts @ np.asarray(H, np.float64).T w = P[:, 2] good = w > 1e-9 if good.any(): rg = rows[good] APPX[rg] = (P[good, 0] / w[good]).astype(np.float32) APPY[rg] = (P[good, 1] / w[good]).astype(np.float32) APPROJ[rg] = True print(f"🧭 Проекция: {int(APPROJ.sum())}/{NAP} ({100*APPROJ.mean():.1f}%) | getH src: {GETHSRC}") if GETHSRC == 'smoothedv31': print(" ⚠️ getH от ячейки 12 (без FLIPY-унификации ячейки 13) — рекомендуется перезапустить 13.")

=====================================================

5. Цвет: fast-path (память 22) или lite-проход

=====================================================

APCOLOR12 = np.full((NAP, 12), np.nan, np.float32)

def embedblocks(c12): """E-эмбеддинг по blocks из teamprototypes.json (аналог colorsembed ячейки 22).""" if not BLOCKS: return c12.copy() vecs = [] for b in BLOCKS: s0, s1 = int(b['slice'][0]), int(b['slice'][1]) keep = np.asarray(b['keep_dims'], int) mean = np.asarray(b['mean'], np.float32) scale = np.maximum(np.asarray(b['scale'], np.float32), 1e-6) part = c12[:, s0:s1][:, keep] vecs.append(((part - mean) / scale).astype(np.float32) / np.sqrt(max(1, len(keep)))) return np.hstack(vecs) if len(vecs) > 1 else vecs[0]

memvars = ('Eall', 'rowofgidx', 'obsgidx', 'obscolor') memok = all(v in globals() and globals()[v] is not None for v in memvars) COLORSRC = None

if memok: obsg = globals()['obsgidx']; obsc = globals()['obscolor'] c22row = globals()['rowofgidx']; Eall = np.asarray(globals()['Eall'], np.float32) DC = int(Eall.shape[1]) APE = np.full((NAP, DC), np.nan, np.float32) ncol = nemb = 0 for i, g in enumerate(obsg): r = APROW.get(int(g)) if r is None: continue c = obsc[i] if c is not None: APCOLOR12[r] = c; ncol += 1 ri = c22row.get(int(g)) if ri is not None: APE[r] = Eall[int(ri)]; nemb += 1 # согласованность с прототипами на диске (диск — источник истины для 29) Eb = embedblocks(APCOLOR12) m = np.isfinite(Eb).all(1) & np.isfinite(APE).all(1) if int(m.sum()) > 10: dmed = float(np.median(np.linalg.norm(Eb[m] - APE[m], axis=1))) if dmed > 1e-2: print(f"⚠️ E в памяти ≠ прототипам на диске (med diff {dmed:.3f}) — использую диск") APE = Eb DC = int(APE.shape[1]) COLORSRC = f'memory22 (цвет {ncol}, E {nemb}; судьи без цвета)' else: # --- lite-функции (копии из 22, без сегментации) --- def clip_box(a, b, c, d, fh, fw): a = max(0, min(fh - 1, a)); b = max(a + 1, min(fh, b)) c = max(0, min(fw - 1, c)); d = max(c + 1, min(fw, d)) return (a, b, c, d)

def partwindows(bbox, fh, fw): x1, y1, x2, y2 = [float(v) for v in bbox] h, w = y2 - y1, x2 - x1 sh = clipbox(int(y1 + 0.15 h), int(y1 + 0.55 h), int(x1 + 0.22 w), int(x1 + 0.78 w), fh, fw) so = clipbox(int(y1 + 0.52 h), int(y1 + 0.80 h), int(x1 + 0.28 w), int(x1 + 0.72 w), fh, fw) return sh, so

def grassref(frame, tb, excludeboxes, padfrac=0.40): fh, fw = frame.shape[:2] h, w = float(tb[3] - tb[1]), float(tb[2] - tb[0]) py, px = int(h pad_frac), int(w padfrac) x1 = max(0, int(tb[0]) - px); y1 = max(0, int(tb[1]) - py) x2 = min(fw, int(tb[2]) + px); y2 = min(fh, int(tb[3]) + py) if x2 - x1 < 8 or y2 - y1 < 8: return None ring = frame[y1:y2, x1:x2] mask = np.ones(ring.shape[:2], bool) bx1, by1 = max(0, int(tb[0]) - x1), max(0, int(tb[1]) - y1) bx2, by2 = min(x2 - x1, int(tb[2]) - x1), min(y2 - y1, int(tb[3]) - y1) if bx2 > bx1 and by2 > by1: mask[by1:by2, bx1:bx2] = False for ob in excludeboxes: ox1 = max(0, int(ob[0]) - x1); oy1 = max(0, int(ob[1]) - y1) ox2 = min(x2 - x1, int(ob[2]) - x1); oy2 = min(y2 - y1, int(ob[3]) - y1) if ox2 > ox1 and oy2 > oy1: mask[oy1:oy2, ox1:ox2] = False if int(mask.sum()) < 80: return None lab = cv2.cvtColor(ring, cv2.COLOR_BGR2LAB) return np.array([np.median(lab[:, :, 0][mask]), np.median(lab[:, :, 1][mask]), np.median(lab[:, :, 2][mask])], np.float32)

def illumshift(bgr, shift): if shift is None: return bgr lab = cv2.cvtColor(bgr, cv2.COLORBGR2LAB).astype(np.float32) lab -= np.asarray(shift, np.float32).reshape(1, 1, 3) return cv2.cvtColor(np.clip(lab, 0, 255).astype(np.uint8), cv2.COLORLAB2BGR)

def partfeature(crop, wp, tchroma, tv): lab = cv2.cvtColor(crop, cv2.COLORBGR2LAB) hsv = cv2.cvtColor(crop, cv2.COLORBGR2HSV) L = lab[:, :, 0].astype(np.float32) A = lab[:, :, 1].astype(np.float32) - 128 B = lab[:, :, 2].astype(np.float32) - 128 C = np.abs(A) + np.abs(B) V = hsv[:, :, 2].astype(np.float32) W = float(wp.sum()) if W < 8: return None chromab = C > tchroma wc = wp chroma_b Sc = float(w_c.sum()) chroma_frac = Sc / W white_frac = float((w_p ((~chromab) & (V > tv))).sum()) / W darkfrac = float((wp ((~chroma_b) & (V <= t_v))).sum()) / W if Sc >= 4: mean_a = float((w_c A).sum() / Sc) / 128.0 meanb = float((wc B).sum() / Sc) / 128.0 rel_L = float(((w_c L).sum() / Sc - (wp * L).sum() / W) / 60.0) else: meana = meanb = relL = 0.0 return np.array([meana, meanb, chromafrac, whitefrac, darkfrac, relL], np.float32)

def litemask(crop): h, w = crop.shape[:2] m = np.zeros((h, w), np.float32) if h < 8 or w < 8: m[:] = 1.0 return m m[int(0.15 h):max(int(0.85 h) + 1, int(0.15 h) + 1), int(0.12 w):max(int(0.88 w) + 1, int(0.12 w) + 1)] = 1.0 return m

if ILL.get('enabled') and GRASSREF is None: print("⚠️ illum включён в FTA, но grassref отсутствует — lite-цвет без нормализации света") t0 = time.perfcounter() for fid, frame in tqdm(itervideoframes(), total=len(TRACKINGFRAMEIDS), desc="Цвет (lite-проход)"): rows = APFRAMEROWS.get(int(fid)) if rows is None or len(rows) == 0: continue fh, fw = frame.shape[:2] idxs = getdetectionindices(int(fid)) boxesall = [[float(DETX1[g]), float(DETY1[g]), float(DETX2[g]), float(DETY2[g])] for g in idxs] for r in rows: tb = np.array([APX1[r], APY1[r], APX2[r], APY2[r]], np.float32) shift = None if GRASSREF is not None: gr = grassref(frame, tb, boxesall) if gr is not None: shift = gr - GRASSREF sh, so = partwindows(tb, fh, fw) fsh = fso = None crop = frame[sh[0]:sh[1], sh[2]:sh[3]] if crop.size > 0: fsh = partfeature(illumshift(crop, shift), litemask(crop), TCHROMA, TV) crop = frame[so[0]:so[1], so[2]:so[3]] if crop.size > 0: fso = partfeature(illumshift(crop, shift), litemask(crop), TCHROMA, TV) if fsh is not None or fso is not None: APCOLOR12[r] = np.concatenate([ fsh if fsh is not None else np.zeros(6, np.float32), fso if fso is not None else np.zeros(6, np.float32)]) APE = embedblocks(APCOLOR12) DC = int(APE.shape[1]) COLORSRC = f'litepass ({time.perfcounter() - t0:.0f} c)'

быстрая сводка цвета до тяжёлых разделов

colok = int(np.isfinite(APCOLOR12).all(1).sum()) print(f"🎨 Цвет: {COLORSRC} | покрытие {colok}/{NAP} ({100*colok/max(1,NAP):.1f}%) | D={D_C}")

=====================================================

6. VL-кэш (body, kind=0): выбор по покрытию gidx

=====================================================

VLGIDX, VLEMB, VLPOS, VLPATH = None, None, {}, None apset = set(APGIDX.tolist()) def ver(p): m = re.search(r'v(\d+)\.npz$', p) return int(m.group(1)) if m else 0 vlfiles = sorted(glob.glob(os.path.join(CACHEDIR, 'vlembeddingsv*.npz')), key=lambda p: -ver(p)) best = None for path in vlfiles: try: with np.load(path) as z: g = z['gidx'].astype(np.int64) k = z['kind'].astype(np.int8) e = z['emb'].astype(np.float32) m0 = k == 0 g0 = g[m0] cov = len(set(g0.tolist()) & apset) print(f" VL-кэш {os.path.basename(path)}: body={int(m0.sum())}, совпадений={cov}") if best is None or cov > best[0]: best = (cov, path, g0, e[m0]) except Exception as ex: print(f" ⚠️ VL-кэш {os.path.basename(path)}: {ex}") if best is not None and best[0] > 0: # кортеж best = (cov, path, gidx, emb) — правильная распаковка VLPATH = best[1] VLGIDX = best[2] VLEMB = best[3] if len(VLGIDX) != len(VLEMB): n = min(len(VLGIDX), len(VLEMB)) print(f"⚠️ VL-кэш: несовпадение длин gidx/emb ({len(VLGIDX)}/{len(VLEMB)}) — обрезано до {n}") VLGIDX, VLEMB = VLGIDX[:n], VLEMB[:n] seen = set(); gi, ei = [], [] for g, e in zip(VLGIDX.tolist(), VLEMB): if g in seen: continue seen.add(g); gi.append(g); ei.append(e) if len(gi) != len(VLGIDX): print(f"⚠️ VL-кэш: удалено дубликатов gidx: {len(VLGIDX) - len(gi)}") VLGIDX = np.asarray(gi, np.int64) VLEMB = np.asarray(ei, np.float32) VLPOS = {int(g): i for i, g in enumerate(VLGIDX)} print(f"✅ VL-кэш выбран: {os.path.basename(VLPATH)} ({len(VL_GIDX)} body-эмбеддингов)") else: print("⚠️ VL-эмбеддинги недоступны — треки по цвету + IoU + плоскости")

=====================================================

7. IoU-рёбра соседних кадров (заготовка parentofgidx)

=====================================================

def ioumat(A, B): ix1 = np.maximum(A[:, None, 0], B[None, :, 0]) iy1 = np.maximum(A[:, None, 1], B[None, :, 1]) ix2 = np.minimum(A[:, None, 2], B[None, :, 2]) iy2 = np.minimum(A[:, None, 3], B[None, :, 3]) iw = np.clip(ix2 - ix1, 0, None); ih = np.clip(iy2 - iy1, 0, None) inter = iw ih aA = (A[:, 2] - A[:, 0]) (A[:, 3] - A[:, 1]) aB = (B[:, 2] - B[:, 0]) * (B[:, 3] - B[:, 1]) return inter / np.maximum(aA[:, None] + aB[None, :] - inter, 1e-6)

ea, eb, ev = [], [], [] prevrows = None for fid in TRACKINGFRAMEIDS: rows = APFRAMEROWS[fid] if prevrows is not None and len(prevrows) and len(rows): A = np.stack([APX1[prevrows], APY1[prevrows], APX2[prevrows], APY2[prevrows]], axis=1) B = np.stack([APX1[rows], APY1[rows], APX2[rows], APY2[rows]], axis=1) M = ioumat(A, B) ii, jj = np.where(M >= IOUEDGEMIN) for i, j in zip(ii, jj): ea.append(int(prevrows[i])); eb.append(int(rows[j])); ev.append(float(M[i, j])) prevrows = rows IOUA = np.asarray(ea, np.int64) IOUB = np.asarray(eb, np.int64) IOUVAL = np.asarray(ev, np.float32)

=====================================================

8. GK-примитивы: видимость зон, счётчики, closest, GK100

=====================================================

fin = np.isfinite(APPX) & np.isfinite(APPY) APINGAL = fin & (APPX >= GAX['L'][0]) & (APPX <= GAX['L'][1]) & (APPY >= GAY[0]) & (APPY <= GAY[1]) APINGAR = fin & (APPX >= GAX['R'][0]) & (APPX <= GAX['R'][1]) & (APPY >= GAY[0]) & (APPY <= GAY[1]) APINPAL = fin & (APPX >= PAX['L'][0]) & (APPX <= PAX['L'][1]) & (APPY >= PAY[0]) & (APPY <= PAY[1]) APINPAR = fin & (APPX >= PAX['R'][0]) & (APPX <= PAX['R'][1]) & (APPY >= PAY[0]) & (APPY <= PAY[1]) APCOLOROUT = np.isfinite(APDIST) & (APDIST > GRAYTHRESH)

def zonepts(side, zone): xs = GAX[side] if zone == 'GA' else PAX[side] ys = GAY if zone == 'GA' else PAY corners = np.array([[xs[0], ys[0]], [xs[0], ys[1]], [xs[1], ys[0]], [xs[1], ys[1]]], np.float64) center = np.array([(xs[0] + xs[1]) / 2.0, (ys[0] + ys[1]) / 2.0], np.float64) return corners, center

def zonevisible(Hinv, corners, center): """Зона видима: центр в кадре ИЛИ >=2 угла в кадре (допуск 5%; w>0).""" pts = np.vstack([center[None, :], corners]) Vh = np.hstack([pts, np.ones((len(pts), 1), np.float64)]) P = Vh @ np.asarray(Hinv, np.float64).T ok = [] for p in P: if p[2] > 1e-6: x, y = p[0] / p[2], p[1] / p[2] ok.append(bool(-0.05 FW <= x <= 1.05 FW and -0.05 FH <= y <= 1.05 FH)) else: ok.append(False) return bool(ok[0] or sum(ok[1:]) >= 2)

GKPFRAMES = {} GK100EVENTS = [] APGK100 = np.zeros(NAP, np.int8)

for fid in tqdm(TRACKINGFRAMEIDS, desc="GK-примитивы"): fid = int(fid) H, Hinv, ok, src = getH(fid) rec = {'visga': {'L': False, 'R': False}, 'vispa': {'L': False, 'R': False}, 'nga': {'L': 0, 'R': 0}, 'npa': {'L': 0, 'R': 0}, 'closest': {'L': -1, 'R': -1}} if ok and Hinv is not None: for side in ('L', 'R'): cga, ctrga = zonepts(side, 'GA') cpa, ctrpa = zonepts(side, 'PA') rec['visga'][side] = zonevisible(Hinv, cga, ctrga) rec['vispa'][side] = zonevisible(Hinv, cpa, ctrpa) rows = APFRAMEROWS[fid] if len(rows): # счётчики и closest — по полевым+GK (судьи исключены) sel = (APCLS[rows] != int(CLSREF)) & APPROJ[rows] pl = rows[sel] if len(pl): for side in ('L', 'R'): inga = pl[APINGAL[pl] if side == 'L' else APINGAR[pl]] inpa = pl[APINPAL[pl] if side == 'L' else APINPAR[pl]] rec['nga'][side] = int(len(inga)) rec['npa'][side] = int(len(inpa)) j = int(pl[int(np.argmin(APPX[pl]))] if side == 'L' else pl[int(np.argmax(APPX[pl]))]) rec['closest'][side] = int(APGIDX[j]) for zone in ('GA', 'PA'): visz = rec['visga'][side] if zone == 'GA' else rec['vispa'][side] inrows = inga if zone == 'GA' else inpa if visz and len(inrows) == 1: r0 = int(inrows[0]) if r0 == j and bool(APCOLOROUT[r0]): code = (1 if zone == 'GA' else 3) + (0 if side == 'L' else 1) APGK100[r0] = code GK100EVENTS.append({ 'fid': fid, 'gidx': int(APGIDX[r0]), 'side': side, 'zone': zone, 'pitch': [round(float(APPX[r0]), 2), round(float(APPY[r0]), 2)], 'team': int(APTEAM[r0]), 'dist': (round(float(APDIST[r0]), 3) if np.isfinite(APDIST[r0]) else None)}) break # GA сильнее PA — достаточно GKPFRAMES[fid] = rec

=====================================================

8b. GK_COLOR: цветовой выброс как ГОЛОСА для трекового решения (правило 5b)

Условие: dist > gray_thresh (форма дальше от ОБЕИХ команд, чем порог серости).

Судьи исключены автоматически (нет dist в FTA). sidehint: pitchx < 52.5 -> L.

Это НЕ метка GK — финальное решение принимает ячейка 30 (GRAY_SHARE + длина

+ GKCLASSSHARE/крайний x + не-судья + нет подтверждённой команды).

=====================================================

APGKCOLOR = np.zeros(NAP, np.int8) mout = APCOLOROUT & np.isfinite(APPX) APGKCOLOR[mout & (APPX < CENTERXM)] = 1 APGKCOLOR[mout & (APPX >= CENTERXM)] = 2 GKCOLOREVENTS = [] for r in np.where(APGKCOLOR > 0)[0]: GKCOLOREVENTS.append({ 'fid': int(APFID[r]), 'gidx': int(APGIDX[r]), 'sidehint': ('L' if APGKCOLOR[r] == 1 else 'R'), 'pitch': [round(float(APPX[r]), 2), round(float(APPY[r]), 2)], 'dist': (round(float(APDIST[r]), 3) if np.isfinite(APDIST[r]) else None), 'team': int(APTEAM[r]), 'gk100': bool(APGK100[r] > 0)}) GKCOLOR_EVENTS.sort(key=lambda e: (e['fid'], e['gidx']))

=====================================================

9. Сохранение

=====================================================

apmeta = { 'videopath': VIDEOPATH, 'npersons': int(NAP), 'nframes': len(TRACKINGFRAMEIDS), 'colorsrc': COLORSRC, 'dcolor': int(DC), 'graythresh': float(GRAYTHRESH), 'vlcache': (os.path.basename(VLPATH) if VLPATH else None), 'vlbody': int(len(VLGIDX)) if VLGIDX is not None else 0, 'iouedgemin': IOUEDGEMIN, 'gk100code': GK100CODE, 'gkcolor': {'centerx': CENTERXM, 'thresh': float(GRAYTHRESH), 'note': 'голоса для трекового правила 5b, не самостоятельная метка GK'}, 'zones': {'GAX': {k: list(v) for k, v in GAX.items()}, 'PAX': {k: list(v) for k, v in PAX.items()}, 'GAY': list(GAY), 'PAY': list(PAY)}, 'notes': ('nga/npa/closest считаются без судей; GK100: vis+solo+closest+colorout; ' 'GKCOLOR: colorout + половина поля; w = ширина bbox для гейта G5b (ячейка 29)'), 'createdunixtime': time.time(), } np.savezcompressed( APCACHEPATH, meta=json.dumps(apmeta), gidx=APGIDX, fid=APFID, cls=APCLS, conf=APCONF, x1=APX1, y1=APY1, x2=APX2, y2=APY2, footx=APFOOTX, footy=APFOOTY, h=APH, w=APW, pitchx=APPX, pitchy=APPY, proj=APPROJ, team=APTEAM, dist=APDIST, colorout=APCOLOROUT, color12=APCOLOR12, E=APE, ingal=APINGAL, ingar=APINGAR, inpal=APINPAL, inpar=APINPAR, gk100=APGK100, gkcolor=APGKCOLOR, vlgidx=(VLGIDX if VLGIDX is not None else np.zeros(0, np.int64)), vlemb=(VLEMB if VLEMB is not None else np.zeros((0, 0), np.float32)), ioua=IOUA, ioub=IOUB, iouv=IOUVAL) print(f"💾 {APCACHEPATH}")

vissh = {k: {'L': float(np.mean([GKPFRAMES[int(f)][k]['L'] for f in TRACKINGFRAMEIDS])), 'R': float(np.mean([GKPFRAMES[int(f)][k]['R'] for f in TRACKINGFRAMEIDS]))} for k in ('visga', 'vispa')} with open(GKPRIMPATH, 'w', encoding='utf-8') as f: json.dump({'meta': {'graythresh': float(GRAYTHRESH), 'zones': apmeta['zones'], 'gk100code': GK100CODE, 'ngk100': len(GK100EVENTS), 'ngkcolor': len(GKCOLOREVENTS), 'gkcolormeta': apmeta['gkcolor'], 'visshare': vissh, 'countsexcludereferees': True}, 'gk100': GK100EVENTS, 'gkcolor': GKCOLOREVENTS, 'frames': {str(fid): GKPFRAMES[fid] for fid in sorted(GKPFRAMES.keys())}}, f, ensureascii=False, separators=(',', ':')) print(f"💾 {GKPRIM_PATH}")

=====================================================

10. Статистика

=====================================================

npairs = max(1, len(TRACKINGFRAMEIDS) - 1) mpl = (APCLS != int(CLSREF)) mfta = np.isin(APFID, np.asarray(sorted(FTAFRAMES.keys()), dtype=np.int64)) labshare = float((APTEAM[mpl & mfta] >= 0).mean()) if int((mpl & mfta).sum()) else 0.0 colshare = float(np.isfinite(APCOLOR12).all(1).mean()) vlshare = (float(np.isin(APGIDX, VLGIDX).mean()) if VLGIDX is not None and len(VLGIDX) else 0.0) gk100by = Counter((e['zone'], e['side']) for e in GK100EVENTS) uniqg = sorted(set(e['gidx'] for e in GK100EVENTS))

print() print("📊 Ячейка 28 — сводка:") print(f" Persons: {NAP} ({NAP/len(TRACKINGFRAMEIDS):.1f}/кадр) | " f"referees: {int((APCLS == int(CLSREF)).sum())}") print(f" Team-метки: {100lab_share:.1f}% полевых на размеченных кадрах | " f"color_out: {int(AP_COLOR_OUT.sum())} ({AP_COLOR_OUT.sum()/n_pairs:.2f}/кадр)") print(f" Цвет: {COLOR_SRC} | покрытие {100colshare:.1f}% | D={DC}") print(f" VL (body): {len(VLGIDX) if VLGIDX is not None else 0} эмбеддингов, " f"персон с VL: {100*vl_share:.1f}%"

  • (f" (кэш {os.path.basename(VLPATH)})" if VLPATH else "")) print(f" Проекция: {int(APPROJ.sum())}/{NAP} ({100AP_PROJ.mean():.1f}%)") print(f" IoU-рёбра (≥{IOU_EDGE_MIN}): {len(IOU_A)} ({len(IOU_A)/n_pairs:.1f}/пару кадров)") print(f" Видимость: GA L {100vissh['visga']['L']:.0f}% / R {100*vissh['visga']['R']:.0f}% | " f"PA L {100_vis_sh['vis_pa']['L']:.0f}% / R {100vissh['vispa']['R']:.0f}%") print(f" GK100: {len(GK100EVENTS)} событий | по зонам: {dict(gk100by)} | " f"уникальных gidx: {len(uniqg)}") lcf = FTAMETA.get('autotune', {}).get('lastcalibfid') if lcf is not None: print(f" Калибровочные кадры (≤{lcf}): {int((APFID <= int(lcf)).sum())} persons без team — ожидаемо") if GK100EVENTS: perg = Counter(e['gidx'] for e in GK100EVENTS) print(f" Топ gidx по GK100-кадрам: {perg.mostcommon(6)}") for side in ('L', 'R'): fs = [e['fid'] for e in GK100_EVENTS if e['side'] == side] if fs: print(f" сторона {side}: {len(fs)} кадров ({min(fs)}..{max(fs)})")

--- GK_COLOR: кандидаты GK по цвету (вне GK100) ---

ngc = len(GKCOLOREVENTS) ngcgk100 = sum(1 for e in GKCOLOREVENTS if e['gk100']) gcside = Counter(e['sidehint'] for e in GKCOLOREVENTS) gcg = Counter(e['gidx'] for e in GKCOLOREVENTS if not e['gk100']) print(f" 🟣 GKCOLOR (выброс цвета, все): {ngc} | из них в GK100: {ngcgk100} | " f"по половинам: L={gcside.get('L', 0)}, R={gcside.get('R', 0)}") if ngc: dall = [e['dist'] for e in GKCOLOREVENTS if e['dist'] is not None] if dall: print(f" dist: p50={np.percentile(dall, 50):.2f}, " f"p95={np.percentile(dall, 95):.2f}, max={max(dall):.2f} " f"(порог {GRAYTHRESH:.2f})") print(f" Уникальных gidx вне GK100: {len(gcg)} | топ: {gcg.mostcommon(8)}") for side in ('L', 'R'): ev = [e for e in GKCOLOREVENTS if e['sidehint'] == side and not e['gk100']] if ev: xs = [e['pitch'][0] for e in ev] fs = [e['fid'] for e in ev] print(f" кандидаты {side} (не-GK100): {len(ev)} событий, " f"кадры {min(fs)}..{max(fs)}, медианный x={np.median(xs):.1f} м")

=====================================================

11. Визуализация: видео + минимапа (контроль GK-примитивов)

=====================================================

TEAMBGR = {0: (0, 140, 255), 1: (255, 80, 80), UNASSIGNED: (128, 128, 128)} SHORT = {0: 'A', 1: 'B', UNASSIGNED: 'X'} GCBGR = (255, 0, 255) # фиолетовый GKRINGBGR = (0, 0, 255) # [v4] красное кольцо вокруг вратаря на миникапе GKRINGR, GKRINGTH = 12, 3

gk100fids = sorted({e['fid'] for e in GK100EVENTS}) gcolLfids = sorted({e['fid'] for e in GKCOLOREVENTS if e['sidehint'] == 'L' and not e['gk100']}) pickgk = pickuniform(gk100fids, min(2, len(gk100fids))) if gk100fids else [] pickgc = pickuniform(gcolLfids, 1 if gcolLfids else 0) busy = set(gk100fids) | set(gcolLfids) others = [f for f in TRACKINGFRAMEIDS if f not in busy] pickot = pickuniform(others, max(0, 4 - len(pickgk) - len(pickgc))) visfids = sorted(set(pickgk + pickgc + pickot))[:4]

need = set(visfids) framesbyid = {} for fid, frame in itervideoframes(): if fid in need: framesby_id[int(fid)] = frame need.discard(int(fid)) if not need: break

ballbyfid = {} bt = os.path.join(OUTPUTDIR, 'balltrack.json') if os.path.exists(bt): try: with open(bt, 'r', encoding='utf-8') as f: ballbyfid = {int(r['frameid']): r for r in json.load(f).get('track', [])} except Exception: ballbyfid = {}

SMM, MMM = 6, 4 MWMM, MHMM = int((105 + 2 M_MM) SMM), int((68 + 2 * MMM) S_MM) def _to_px(x, y): return int((x + M_MM) SMM), int((y + MMM) S_MM) MM_CANVAS = np.zeros((MH_MM, MW_MM, 3), np.uint8) MM_CANVAS[:] = (12, 62, 12) for (a, b) in PITCH_CONFIG.edges: cv2.line(MM_CANVAS, _to_px(PITCHVERTICESM[a - 1]), topx(PITCH_VERTICES_M[b - 1]), (230, 230, 230), 1, cv2.LINE_AA) cv2.circle(MM_CANVAS, _to_px(52.5, 34.0), int(round(9.15 SMM)), (230, 230, 230), 1, cv2.LINEAA)

центральная линия на миникапе — граница side_hint

cv2.line(MMCANVAS, topx(CENTERXM, 0), topx(CENTERXM, 68), (90, 140, 90), 1, cv2.LINEAA)

def dashline(img, p1, p2, col, thick, dash=6, gap=5): p1 = np.array(p1, float); p2 = np.array(p2, float) d = p2 - p1; L = float(np.hypot(d)) if L < 1: return u = d / L; t = 0.0 while t < L: t2 = min(t + dash, L) a = (p1 + u t).astype(int); b = (p1 + u * t2).astype(int) cv2.line(img, tuple(a), tuple(b), col, thick, cv2.LINE_AA) t = t2 + gap

def rectmm(img, xs, ys, col, thick, dash=False): p1 = topx(xs[0], ys[0]); p2 = topx(xs[1], ys[1]) if not dash: cv2.rectangle(img, p1, p2, col, thick, cv2.LINEAA) else: for a, b in ((p1, (p2[0], p1[1])), ((p2[0], p1[1]), p2), (p2, (p1[0], p2[1])), ((p1[0], p2[1]), p1)): dash_line(img, a, b, col, thick)

def drawvideo(frame, fid): vis = frame.copy() for r in APFRAMEROWS[fid]: t = int(APTEAM[r]); cls = int(APCLS[r]) gk = int(APGK100[r]); gc = int(APGKCOLOR[r]) x1, y1, x2, y2 = int(APX1[r]), int(APY1[r]), int(APX2[r]), int(APY2[r]) if gk: col = (0, 165, 255); lab = f"GK100 {GK100CODE[gk]}"; th = 3 elif gc: col = GCBGR; lab = f"GC {'L' if gc == 1 else 'R'}"; th = 3 elif cls == int(CLSREF): col = (200, 200, 200); lab = 'REF'; th = 1 elif t in (0, 1): col = TEAMBGR[t]; lab = SHORT[t]; th = 2 else: col = (128, 128, 128); lab = 'X' if t == UNASSIGNED else '?'; th = 2 cv2.rectangle(vis, (x1, y1), (x2, y2), col, th) cv2.putText(vis, lab, (x1, max(12, y1 - 6)), cv2.FONTHERSHEYSIMPLEX, 0.5, col, 2, cv2.LINEAA) cv2.circle(vis, (int(APFOOTX[r]), int(APFOOTY[r])), 2, col, -1) rec = GKPFRAMES[fid] hdr = (f"f{fid} | visGA L{int(rec['visga']['L'])} R{int(rec['visga']['R'])} | " f"visPA L{int(rec['vispa']['L'])} R{int(rec['vispa']['R'])}") cv2.rectangle(vis, (0, 0), (vis.shape[1], 38), (0, 0, 0), -1) cv2.putText(vis, hdr, (10, 27), cv2.FONTHERSHEYSIMPLEX, 0.7, (255, 255, 255), 2, cv2.LINEAA) return vis

def drawmini(fid): mini = MMCANVAS.copy() rec = GKPFRAMES[fid] for side in ('L', 'R'): rectmm(mini, GAX[side], GAY, (230, 230, 230), 2, dash=not rec['visga'][side]) rectmm(mini, PAX[side], PAY, (230, 230, 230), 1, dash=not rec['vispa'][side]) for r in APFRAMEROWS[fid]: if not APPROJ[r]: continue cls = int(APCLS[r]); t = int(APTEAM[r]) gk = int(APGK100[r]); gc = int(APGKCOLOR[r]) if cls == int(CLSREF): col = (230, 230, 230) elif t in (0, 1): col = TEAMBGR[t] else: col = (128, 128, 128) c = topx(float(APPX[r]), float(APPY[r])) if gk or gc: # [v4] вратарь: цветная точка (оранжевая GK100 / фиолетовая GC) # + яркое КРАСНОЕ кольцо поверх — заметность на миникапе inner = (0, 165, 255) if gk else GCBGR cv2.circle(mini, c, 6, inner, -1, cv2.LINEAA) cv2.circle(mini, c, GKRINGR, GKRINGBGR, GKRINGTH, cv2.LINEAA) else: cv2.circle(mini, c, 4, col, -1, cv2.LINEAA) for side in ('L', 'R'): g = rec['closest'][side] if g >= 0 and g in APROW: r = APROW[g] if APPROJ[r]: c = topx(float(APPX[r]), float(APPY[r])) cv2.circle(mini, c, 8, (255, 255, 0), 2, cv2.LINEAA) b = ballbyfid.get(fid) if b is not None and b.get('pitchx') is not None: c = topx(float(b['pitchx']), float(b['pitchy'])) cv2.circle(mini, c, 4, (0, 255, 255), -1, cv2.LINEAA) return mini

if visfids and framesbyid: ncols = len(visfids) fig, axes = plt.subplots(2, ncols, figsize=(5.0 * ncols, 7.0)) axes = np.array(axes).reshape(2, ncols) for ci, fid in enumerate(visfids): if fid not in framesbyid: axes[0, ci].axis('off'); axes[1, ci].axis('off') continue vimg = drawvideo(framesbyid[fid], fid) mimg = drawmini(fid) axes[0, ci].imshow(cv2.cvtColor(vimg, cv2.COLORBGR2RGB)) tag = (" [GK100]" if fid in set(gk100fids) else (" [GC-L]" if fid in set(gcolLfids) else "")) axes[0, ci].settitle(f"f{fid}{tag}", fontsize=11) axes[1, ci].imshow(cv2.cvtColor(mimg, cv2.COLORBGR2RGB)) axes[1, ci].settitle(f"closest L:{GKPFRAMES[fid]['closest']['L']} " f"R:{GKPFRAMES[fid]['closest']['R']}", fontsize=10) axes[0, ci].axis('off'); axes[1, ci].axis('off') cv2.imwrite(os.path.join(VISDIR, f"video{fid:06d}.jpg"), vimg, [int(cv2.IMWRITEJPEGQUALITY), 88]) cv2.imwrite(os.path.join(VISDIR, f"mini{fid:06d}.jpg"), mimg, [int(cv2.IMWRITEJPEGQUALITY), 88]) plt.suptitle("Ячейка 28 v4: 🔴 КРАСНОЕ КОЛЬЦО = вратарь (точка: оранжевая=GK100, фиолетовая=GC) | " "голубое кольцо=ближайший к воротам | пунктир=зона не видна | жёлтый=мяч", fontsize=12) plt.tightlayout() plt.show() print(f"🖼️ Панели сохранены: {VIS_DIR}")

if callable(globals().get('freememory')): freememory()

print() print("✅ Ячейка 28 v4 готова: appearancecache.npz + gkframeprimitives.json") print(" Для ячейки 29: AP* массивы (включая APW, APGKCOLOR), APFRAMEROWS, APROW,") print(" VLPOS/VLGIDX/VLEMB, IOUA/B/VAL, GKPFRAMES, GK100EVENTS, GKCOLOREVENTS,") print(" GRAYTHRESH, UNASSIGNED, PROTO, FTAFRAMES, CENTERX_M")

@title 28G v3. Этап 0 GK-блока: DIST_TEAM + per-frame контексты + Кр4 с гейтом ворот

#

v3 = v2 + утверждённые изменения ТЗ:

[1] TCROWD 1.8 -> 2.2 (порог DISTTEAM кандидата Кр4);

[2] T_MATCH 1.0 -> 1.2 (цветовой порог трекинга; используется в 29G);

[3] НОВОЕ GATEGOALM = 30: кандидат Кр1/Кр4 должен находиться не дальше 30 м

от центра СВОИХ ворот. Гейт встроен в покадровые kr4-флаги; для Кр1

экспортируются APGOALDL/APGOALDR (дистанции детекций до центров

ворот L/R). Устраняет ложную селекцию GK_R в f0–f5 (dry-run D2:

цепь x=68.9 -> 36.2 м от ворот, цепь x=54.9 -> 50 м — обе отсекаются;

настоящий GKL на x~22 -> 22.5 м, GKR ~3-5 м — проходят);

[4] Лимит «<=1 новый трек на момент» трактуется PER-SIDE (каждая сторона

селектирует независимо; фиксируется в meta, реализация — в 29G).

Кр2/Кр3 гейтом не трогаются (зоны GA/PA ограничивают позицию сами).

Экспорт: APDISTDIRECT, APGOALDL/DR, GKC_FRAMES (kr4 с гейтом),

константы ТЗ, патч appearancecache.npz + gkcontext_frames.json.

Без чтения видео; детерминировано; секунды.

import os, json, time import numpy as np from collections import Counter

================== КОНСТАНТЫ ТЗ (разд. 2; единые для 28G–31G) ==================

TOUT = 2.5 # селекц. Кр1: цветовой выброс от обеих команд TCROWD = 2.2 # селекц. Кр4: порог DISTTEAM кандидата (v3: было 1.8) TMATCH = 1.2 # трекинг: цветовой порог соответствия (v3: было 1.0) GATEGOALM = 30.0 # v3: Кр1/Кр4 — кандидат не дальше 30 м от центра своих ворот SELWINDOW = 6 # кадров в окне селекции SELSTREAK = 4 # подряд кадров для Кр3 SELMINFRAMES = 2 # минимум кадров окна с подтверждением (анти-одиночный шум) CROWDMIN = 21 # Кр4: минимум детекций в кадре (вкл. GK, без судей) VMAXGK = 10.0 # м/с, потолок эффективной скорости поиска RBASE = 2.5 # м, базовый радиус ассоциации KEXPAND = 1.15 # коэффициент роста скорости поиска за кадр потери RCAP = 40.0 # м, потолок радиуса при потере CMAX = 1.2 # порог cost Венгра CENTERX = 52.5 # граница половин поля WPOS, WDIR, WCOL = 0.5, 0.2, 0.3 # веса cost: позиция / направление / цвет

зоны (метры)

GAY = (24.84, 43.16); PAY = (13.84, 54.16) GAX = {'L': (-1.0, 5.5), 'R': (99.5, 106.0)} PAX = {'L': (-1.0, 16.5), 'R': (88.5, 106.0)} GOAL_CENTER = {'L': (0.0, 34.0), 'R': (105.0, 34.0)}

классы (дефолты соответствуют модели; при живой сессии берутся из globals)

CLSBALL = int(globals().get('CLSBALL', 0)) CLSGK = int(globals().get('CLSGK', 1)) CLSPLAYER = int(globals().get('CLSPLAYER', 2)) CLSREF = int(globals().get('CLSREF', 3))

t00 = time.perfcounter() CELLTAG = '28G v3'

print("📐 Кр4 (v3): в кадре ≥ 21 детекции (вкл. GK, без судей) ∧ кандидат на своей " "половине ∧ ближайший к центру линии своих ворот ∧ DISTTEAM > 2.2 ∧ " f"не дальше {GATEGOAL_M:.0f} м от центра своих ворот") print("📐 Кр1 (v3, реализация в 29G): цветовой выброс + тот же гейт ворот; " "лимит «≤1 новый трек на момент» — per-side.")

=====================================================

1. Источники: AP_* (память ячейки 28 | кэш), прототипы, GKP, FTA, VL

=====================================================

CACHEDIR = str(globals().get('CACHEDIR', '/content/cache')) OUTPUTDIR = str(globals().get('OUTPUTDIR', '/content/output')) APCACHEPATH = os.path.join(CACHEDIR, 'appearancecache.npz') GKPRIMPATH = os.path.join(OUTPUTDIR, 'gkframeprimitives.json') FTAPATH = os.path.join(OUTPUTDIR, 'frameteamassignment.json') PROTOPATH = os.path.join(OUTPUTDIR, 'teamprototypes.json') GKCPATH = os.path.join(CACHEDIR, 'gkcontextframes.json') os.makedirs(CACHEDIR, existok=True); os.makedirs(OUTPUTDIR, existok=True)

memvars = ['APGIDX', 'APFID', 'APCLS', 'APCONF', 'APX1', 'APY1', 'APX2', 'APY2', 'APFOOTX', 'APFOOTY', 'APPX', 'APPY', 'APPROJ', 'APTEAM', 'APDIST', 'APCOLOR12'] APSRC = 'memory28' if all(v in globals() and globals()[v] is not None for v in memvars): VLGIDX = globals().get('VLGIDX') VLEMB = globals().get('VLEMB') APECACHED = globals().get('APE') else: assert os.path.exists(APCACHEPATH), \ f"❌ Нет AP* в памяти и нет {APCACHEPATH} — выполните ячейку 28 v4." with np.load(APCACHEPATH) as z: APGIDX = z['gidx'].astype(np.int64) APFID = z['fid'].astype(np.int32) APCLS = z['cls'].astype(np.int8) APCONF = z['conf'].astype(np.float32) APX1 = z['x1'].astype(np.float32); APY1 = z['y1'].astype(np.float32) APX2 = z['x2'].astype(np.float32); APY2 = z['y2'].astype(np.float32) APFOOTX = z['footx'].astype(np.float32); APFOOTY = z['footy'].astype(np.float32) APPX = z['pitchx'].astype(np.float32); APPY = z['pitchy'].astype(np.float32) APPROJ = z['proj'].astype(bool) APTEAM = z['team'].astype(np.int8); APDIST = z['dist'].astype(np.float32) APCOLOR12 = z['color12'].astype(np.float32) VLGIDX = (z['vlgidx'].astype(np.int64) if 'vlgidx' in z.files else np.zeros(0, np.int64)) VLEMB = (z['vlemb'].astype(np.float32) if 'vlemb' in z.files else np.zeros((0, 0), np.float32)) APECACHED = (z['E'].astype(np.float32) if 'E' in z.files else None) AP_SRC = 'cache'

NAP = len(APGIDX) assert N_AP > 0, "❌ Пустой AP-набор."

--- APFRAMEROWS / APROW / VLPOS (восстановление при рестарте) ---

if (not isinstance(globals().get('APFRAMEROWS'), dict) or not globals()['APFRAMEROWS'] or APSRC == 'cache'): order = np.argsort(APFID, kind='stable') f = APFID[order] u, s = np.unique(f, returnindex=True) e = np.append(s[1:], len(f)) APFRAMEROWS = {int(u): np.sort(order[s:e]) for u, s, e in zip(u, s, e)} APROW = {int(g): i for i, g in enumerate(APGIDX.tolist())} VLPOS = {int(g): i for i, g in enumerate(VLGIDX.tolist())} if VLGIDX is not None and len(VLGIDX) else {} if VLGIDX is None: VLGIDX = np.zeros(0, np.int64); VLEMB = np.zeros((0, 0), np.float32)

if 'TRACKINGFRAMEIDS' not in globals() or not TRACKINGFRAMEIDS: TRACKINGFRAMEIDS = sorted(APFRAMEROWS.keys()) TRACKINGFRAMEIDS = [int(f) for f in TRACKINGFRAMEIDS]

--- прототипы команд (диск — источник истины) ---

assert os.path.exists(PROTOPATH), "❌ teamprototypes.json не найден — выполните ячейку 22." with open(PROTOPATH, 'r', encoding='utf-8') as f: PROTO = json.load(f) BLOCKS = PROTO.get('blocks', []) CENTS = np.asarray(PROTO['centroidsscaled'], np.float32) assert CENTS.shape[0] == 2, "❌ centroidsscaled: ожидаются 2 центроида" GRAYTHRESH = float(PROTO.get('gray_thresh', 3.5))

--- GKPFRAMES (visga/vis_pa для Кр2/Кр3 в 29G) ---

if not (isinstance(globals().get('GKPFRAMES'), dict) and globals()['GKPFRAMES']): if os.path.exists(GKPRIMPATH): with open(GKPRIMPATH, 'r', encoding='utf-8') as f: gp = json.load(f) GKPFRAMES = {int(k): v for k, v in gp.get('frames', {}).items()} else: GKPFRAMES = {} print("⚠️ gkframeprimitives.json недоступен: Кр2/Кр3 в 29G будут без видимости зон")

--- FTA (team-голоса для 29G) + meta (lastcalibfid) ---

if not (isinstance(globals().get('FTAFRAMES'), dict) and globals()['FTAFRAMES']): assert os.path.exists(FTAPATH), "❌ frameteamassignment.json не найден — выполните ячейку 22." with open(FTAPATH, 'r', encoding='utf-8') as f: fta = json.load(f) FTAFRAMES = {int(k): v for k, v in fta.get('frames', {}).items()} FTAMETA = fta.get('meta', {}) else: FTAMETA = globals().get('FTAMETA') or {} if not isinstance(FTAMETA, dict) or not FTAMETA: if os.path.exists(FTAPATH): with open(FTAPATH, 'r', encoding='utf-8') as f: FTAMETA = json.load(f).get('meta', {})

=====================================================

2. E в шкале прототипов с диска -> APDISTDIRECT (Определение 1)

+ APGOALDL/DR: дистанции детекций до центров ворот (гейт Кр1 в 29G)

=====================================================

def embedblocks(c12): """E-эмбеддинг по blocks из teamprototypes.json (шкала centroidsscaled).""" if not BLOCKS: return np.asarray(c12, np.float32).copy() vecs = [] for b in BLOCKS: s0, s1 = int(b['slice'][0]), int(b['slice'][1]) keep = np.asarray(b['keep_dims'], int) mean = np.asarray(b['mean'], np.float32) scale = np.maximum(np.asarray(b['scale'], np.float32), 1e-6) part = c12[:, s0:s1][:, keep] vecs.append(((part - mean) / scale).astype(np.float32) / np.sqrt(max(1, len(keep)))) return np.hstack(vecs) if len(vecs) > 1 else vecs[0]

EGK = embedblocks(APCOLOR12) DC = int(EGK.shape[1]) assert DC == int(CENTS.shape[1]), \ f"❌ D(color)={DC} != D(centroids)={CENTS.shape[1]} — прототипы от другой версии ячейки 22?"

if APECACHED is not None and np.shape(APECACHED) == EGK.shape: m = np.isfinite(np.asarray(APECACHED, np.float32)).all(1) & np.isfinite(EGK).all(1) if int(m.sum()) > 10: dmed = float(np.median(np.linalg.norm( np.asarray(APECACHED, np.float32)[m] - EGK[m], axis=1))) etag = " ⚠️ рассинхрон (>0.01) — использую шкалу прототипов с диска" if dmed > 1e-2 else "" print(f"🔎 E: память/кэш vs прототипы-диска: med|ΔE|={dmed:.4f}{etag}")

isref = APCLS == int(CLSREF) efin = np.isfinite(EGK).all(axis=1) d0 = np.linalg.norm(EGK - CENTS[0][None, :], axis=1) d1 = np.linalg.norm(EGK - CENTS[1][None, :], axis=1) APDISTDIRECT = np.full(NAP, np.nan, np.float32) ok = efin & (~isref) APDISTDIRECT[ok] = np.minimum(d0[ok], d1[ok]) # ref: NaN по определению

дистанции до центров ворот (NaN без проекции; гейт Кр1/Кр4)

APGOALDL = np.hypot(APPX - GOALCENTER['L'][0], APPY - GOALCENTER['L'][1]).astype(np.float32) APGOALDR = np.hypot(APPX - GOALCENTER['R'][0], APPY - GOALCENTER['R'][1]).astype(np.float32)

=====================================================

3. Per-frame контексты GKC_FRAMES (v3: kr4 с гейтом ворот)

present = не-ref (n_field: вкл. GK-класс, без вычета закреплённых);

usable = не-ref и не закреплённые (пул зон/closest);

kr4[S].ok = popok ∧ closest(S) ∧ DISTTEAM(closest) > T_CROWD ∧

goaldist(closest) <= GATEGOAL_M.

=====================================================

GKEXCL = globals().get('GKEXCLUDEDGIDX', None) excl = (np.isin(APGIDX, np.asarray(sorted(GKEXCL), np.int64)) if GKEXCL else np.zeros(N_AP, bool))

present = (~isref) usable = (~isref) & (~excl) finp = usable & np.isfinite(APPX) & np.isfinite(AP_PY)

inga, inpa, half = {}, {}, {} for s in ('L', 'R'): inga[s] = finp & (APPX >= GAX[s][0]) & (APPX <= GAX[s][1]) \ & (APPY >= GAY[0]) & (APPY <= GAY[1]) inpa[s] = finp & (APPX >= PAX[s][0]) & (APPX <= PAX[s][1]) \ & (APPY >= PAY[0]) & (APPY <= PAY[1]) half['L'] = APPX < CENTERX half['R'] = APPX >= CENTER_X

GKCFRAMES = {} for fid in TRACKINGFRAMEIDS: fid = int(fid) rows = APFRAMEROWS.get(fid, np.zeros(0, np.int64)) n = len(rows) rec = {'nfield': int(present[rows].sum()) if n else 0, 'ngaexcl': {'L': 0, 'R': 0}, 'npaexcl': {'L': 0, 'R': 0}, 'gasologidx': {'L': None, 'R': None}, 'pasologidx': {'L': None, 'R': None}, 'closest': {'L': None, 'R': None}, 'kr4': {'L': None, 'R': None}} for s in ('L', 'R'): if n: rga = rows[inga[s][rows]] rpa = rows[inpa[s][rows]] else: rga = np.zeros(0, np.int64); rpa = np.zeros(0, np.int64) rec['ngaexcl'][s] = int(len(rga)) rec['npaexcl'][s] = int(len(rpa)) if len(rga) == 1: rec['gasologidx'][s] = int(APGIDX[rga[0]]) if len(rpa) == 1: rec['pasologidx'][s] = int(APGIDX[rpa[0]]) m = (finp[rows] & half[s][rows]) if n else np.zeros(0, bool) if m.any(): rr = rows[m] gx, gy = GOALCENTER[s] d = np.hypot(APPX[rr] - gx, APPY[rr] - gy) j = int(np.argmin(d)) rec['closest'][s] = {'gidx': int(APGIDX[rr[j]]), 'row': int(rr[j]), 'dist': round(float(d[j]), 3), 'px': round(float(APPX[rr[j]]), 2), 'py': round(float(APPY[rr[j]]), 2)} # --- Кр4 (v3): популяция ∧ closest ∧ цвет ∧ ГЕЙТ ВОРОТ --- cl = rec['closest'][s] popok = bool(rec['nfield'] >= CROWDMIN) if cl is not None: r = int(cl['row']) dd = float(APDISTDIRECT[r]) if np.isfinite(APDISTDIRECT[r]) else None gd = float(cl['dist']) # дистанция до goalcenter(S) distok = (dd is not None) and (dd > TCROWD) gateok = gd <= GATEGOALM else: dd = None; gd = None; distok = False; gateok = False rec['kr4'][s] = {'nfield': int(rec['nfield']), 'popok': popok, 'gidx': (cl['gidx'] if cl is not None else None), 'dist': (round(dd, 3) if dd is not None else None), 'distok': bool(distok), 'goaldist': (round(gd, 2) if gd is not None else None), 'gateok': bool(gateok), 'ok': bool(popok and distok and gateok)} GKCFRAMES[fid] = rec

=====================================================

4. Патч appearancecache.npz + gkcontext_frames.json

=====================================================

kr4spec = {'popmin': int(CROWDMIN), 'distthresh': float(TCROWD), 'gategoalm': float(GATEGOALM), 'nfielddef': ('все не-ref детекции кадра, включая класс GK; ' 'закреплённые GK-детекции не вычитаются'), 'canddef': ('closest к goalcenter(S) среди незакреплённых не-ref ' 'детекций с валидной проекцией на половине S'), 'okdef': 'popok ∧ closest ∧ DISTTEAM>TCROWD ∧ goaldist<=GATEGOALM'} amendments = {'cell': CELLTAG, 'changes': ['TCROWD 1.8 -> 2.2 (утверждено)', 'TMATCH 1.0 -> 1.2 (утверждено)', 'GATEGOALM=30: гейт ворот для Кр1/Кр4 (утверждено)', 'лимит "<=1 новый трек на момент" — per-side (утверждено; ' 'реализация в 29G)']} gkstage0 = {'cell': CELLTAG, 'tout': TOUT, 'tcrowd': TCROWD, 'tmatch': TMATCH, 'gategoalm': GATEGOALM, 'selwindow': SELWINDOW, 'selstreak': SELSTREAK, 'selminframes': SELMINFRAMES, 'crowdmin': CROWDMIN, 'vmaxgk': VMAXGK, 'rbase': RBASE, 'kexpand': KEXPAND, 'rcap': RCAP, 'cmax': CMAX, 'centerx': CENTERX, 'wpos': WPOS, 'wdir': WDIR, 'wcol': WCOL, 'kr4spec': kr4spec, 'amendments': amendments, 'exclapplied': int(excl.sum())}

if os.path.exists(APCACHEPATH): with np.load(APCACHEPATH) as z: patch = {k: z[k] for k in z.files} if len(patch.get('gidx', np.zeros(0))) != NAP: print("⚠️ appearancecache.npz от другого сегмента (N≠) — патч НЕ записан; " "перезапустите ячейку 28 v4.") else: needwrite = False prev = patch.get('distdirect') if not (prev is not None and prev.shape == APDISTDIRECT.shape and bool(np.allclose(prev, APDISTDIRECT, equalnan=True))): patch['distdirect'] = APDISTDIRECT needwrite = True prevl = patch.get('goaldl') if not (prevl is not None and prevl.shape == APGOALDL.shape and bool(np.allclose(prevl, APGOALDL, equalnan=True))): patch['goaldl'] = APGOALDL; patch['goaldr'] = APGOALDR needwrite = True meta = {} if 'meta' in patch: try: mm = patch['meta'] meta = json.loads(mm.item() if hasattr(mm, 'item') else str(mm)) except Exception: meta = {} if not isinstance(meta, dict) or meta.get('gkstage0', {}).get('cell') != CELLTAG: meta['gkstage0'] = gkstage0 patch['meta'] = np.array(json.dumps(meta)) needwrite = True if needwrite: np.savezcompressed(APCACHEPATH, **patch) print(f"💾 appearancecache.npz: обновлён (distdirect / goaldl,dr / meta {CELLTAG})") else: print(f"📦 appearancecache.npz: уже актуален для {CELLTAG}") else: print("⚠️ appearancecache.npz не найден — distdirect/goal_dl/dr только в globals")

gkcmeta = {'cell': CELLTAG, 'nframes': len(GKCFRAMES), 'apsrc': APSRC, 'npersons': int(NAP), 'graythresh': GRAYTHRESH, 'tout': TOUT, 'tcrowd': TCROWD, 'crowdmin': CROWDMIN, 'gategoalm': GATEGOALM, 'centerx': CENTERX, 'exclgkgidx': int(excl.sum()), 'kr4spec': kr4spec, 'amendments': amendments, 'goalcenter': {k: list(v) for k, v in GOALCENTER.items()}, 'zones': {'GAX': {k: list(v) for k, v in GAX.items()}, 'PAX': {k: list(v) for k, v in PAX.items()}, 'GAY': list(GAY), 'PAY': list(PAY)}, 'createdunixtime': time.time()} with open(GKCPATH, 'w', encoding='utf-8') as f: json.dump({'meta': gkcmeta, 'frames': {str(k): GKCFRAMES[k] for k in sorted(GKCFRAMES)}}, f, ensureascii=False, separators=(',', ':')) print(f"💾 gkcontextframes.json: {len(GKCFRAMES)} кадров (kr4 с гейтом, {CELLTAG})")

=====================================================

5. Диагностика

=====================================================

mpl = APCLS == int(CLSPLAYER) mgk = APCLS == int(CLSGK) fids = sorted(GKCFRAMES.keys())

def _tm(c): if not c: return '—' m = {-1: 'нет', 0: 't0', 1: 't1', 2: 'X'} return '/'.join(f"{m.get(k, k)}:{v}" for k, v in sorted(c.items()))

print() print("📊 Ячейка 28G v3 — сводка Этапа 0:") print(f" Persons: {NAP} ({NAP/len(fids):.1f}/кадр) | источник AP: {APSRC} | " f"D={DC} | graythresh={GRAYTHRESH:.2f} | excl GK-детекций: {int(excl.sum())}") covpl = float(np.isfinite(APDISTDIRECT[mpl]).mean()) if int(mpl.sum()) else 0.0 covgk = float(np.isfinite(APDISTDIRECT[mgk]).mean()) if int(mgk.sum()) else 0.0 print(f" DISTTEAM покрытие: player {100*covpl:.1f}% | gk {100*covgk:.1f}% " f"| ref 0% (по определению)") for nm, m in (('player', mpl), ('gk', mgk)): v = APDISTDIRECT[m & np.isfinite(APDISTDIRECT)] if len(v): print(f" DISTTEAM[{nm}]: n={len(v)} p50={np.percentile(v,50):.2f} " f"p90={np.percentile(v,90):.2f} p95={np.percentile(v,95):.2f} " f"max={v.max():.2f} | >TOUT({TOUT}): {100*float((v>TOUT).mean()):.1f}% " f"| >TCROWD({TCROWD}): {100*float((v>T_CROWD).mean()):.1f}%")

--- sanity: FTA dist vs DIST_DIRECT ---

cmp = np.isfinite(APDIST) & np.isfinite(APDISTDIRECT) & (~isref) if int(cmp.sum()) > 10: diff = np.abs(APDIST[cmp].astype(np.float64) - APDISTDIRECT[cmp].astype(np.float64)) bad = float((diff > 0.05).mean()) print(f" 🤝 Sanity FTA dist vs DISTDIRECT: n={int(cmp.sum())}, " f"med|Δ|={float(np.median(diff)):.4f}, p95={float(np.percentile(diff,95)):.4f}, " f">0.05: {100*bad:.2f}%" + (" ⚠️ рассинхрон прототипов!" if _bad > 0.01 else " ✅"))

--- Кр4 из флагов (с гейтом) ---

print(f"\n🎯 Кр4 (≥{CROWDMIN} детекций вкл. GK без судей ∧ closest на своей половине " f"∧ DISTTEAM>{TCROWD} ∧ ≤{GATEGOALM:.0f} м от центра своих ворот):") for s in ('L', 'R'): kr = [GKCFRAMES[f]['kr4'][s] for f in fids] br = Counter() for k in kr: if k['ok']: br['ok'] += 1 elif not k['popok']: br['pop<CROWDMIN'] += 1 elif k['gidx'] is None: br['noclosest'] += 1 elif not k['gateok']: br['gate>30m'] += 1 elif k['dist'] is None: br['distnan'] += 1 else: br['dist<=TCROWD'] += 1 oks = [f for f in fids if GKCFRAMES[f]['kr4'][s]['ok']] if not oks: print(f" Сторона {s}: ok=0 | breakdown: {dict(br)}") continue rows = np.asarray([GKCFRAMES[f]['closest'][s]['row'] for f in oks], np.int64) dv = APDISTDIRECT[rows] pxv = APPX[rows]; pyv = APPY[rows] mx, my = float(np.median(pxv)), float(np.median(pyv)) cons = float(np.mean(np.hypot(pxv - mx, pyv - my) <= 5.0)) clsc = Counter('gk' if int(APCLS[r]) == CLSGK else 'pl' for r in rows) tmc = Counter(int(APTEAM[r]) for r in rows) print(f" Сторона {s}: ok={len(oks)} ({100*len(oks)/len(fids):.0f}%) | " f"первый ok f{oks[0]}, последний f{oks[-1]} | breakdown: {dict(br)}") print(f" кандидат: DISTTEAM med={np.median(dv):.2f} " f"p10/p90={np.percentile(dv,10):.2f}/{np.percentile(dv,90):.2f} | " f"pos med=({mx:.1f},{my:.1f}) σ=({np.std(pxv):.1f},{np.std(pyv):.1f}) | " f"консистентность (≤5 м от медианы): {100*cons:.0f}%") print(f" классы кандидата: {dict(clsc)} | FTA: {tm(tmc)}")

--- Кр2/Кр3-готовность ---

print(f"\n🎯 Кр2/Кр3-готовность (зона видима ∧ solo ∧ solo==closest):") for s in ('L', 'R'): kr2 = [f for f in fids if GKPFRAMES.get(f, {}).get('visga', {}).get(s, False) and GKCFRAMES[f]['gasologidx'][s] is not None and GKCFRAMES[f]['closest'][s] is not None and GKCFRAMES[f]['gasologidx'][s] == GKCFRAMES[f]['closest'][s]['gidx']] kr3 = [f for f in fids if GKPFRAMES.get(f, {}).get('vispa', {}).get(s, False) and GKCFRAMES[f]['pasologidx'][s] is not None and GKCFRAMES[f]['closest'][s] is not None and GKCFRAMES[f]['pasologidx'][s] == GKCFRAMES[f]['closest'][s]['gidx']] visga = (float(np.mean([GKPFRAMES.get(f, {}).get('visga', {}).get(s, False) for f in fids])) if GKPFRAMES else float('nan')) vispa = (float(np.mean([GKPFRAMES.get(f, {}).get('vispa', {}).get(s, False) for f in fids])) if GKPFRAMES else float('nan')) f2 = f" (первый f{kr2[0]})" if kr2 else "" f3 = f" (первый f{kr3[0]})" if kr3 else "" print(f" Сторона {s}: visga≈{100*visga:.0f}% vispa≈{100*vispa:.0f}% | " f"Кр2: {len(kr2)}{f2} | Кр3: {len(kr3)}{f3}")

--- популяция кадров ---

nf = np.array([GKCFRAMES[f]['nfield'] for f in fids]) print(f"\n👥 nfield (все не-ref, вкл. GK): mean={nf.mean():.1f} min={int(nf.min())} " f"max={int(nf.max())} | ≥CROWDMIN({CROWDMIN}): " f"{100*float((nf >= CROWDMIN).mean()):.0f}% кадров")

--- 🔧 Проверка исправления: окна f0–f(SEL_WINDOW-1) + первые ok-кадры ---

print(f"\n🔧 Проверка исправления (гейт {GATEGOALM:.0f} м + TCROWD={TCROWD}), " f"окна первых кадров:") for fid in fids[:SELWINDOW]: gkc = GKCFRAMES[fid] parts = [f"f{fid}: nfield={gkc['nfield']}"] for s in ('L', 'R'): k4 = gkc['kr4'][s] if k4['ok']: parts.append(f"Кр4[{s}]=OK d={k4['dist']:.2f} goal={k4['goaldist']:.1f}м") else: why = ('pop' if not k4['popok'] else 'нет closest' if k4['gidx'] is None else f"гейт {k4['goaldist']:.1f}м" if not k4['gateok'] else 'NaN' if k4['dist'] is None else f"dist {k4['dist']:.2f}≤{TCROWD}") parts.append(f"Кр4[{s}]=✂({why})") print(' ' + ' | '.join(parts)) for s in ('L', 'R'): outs = [] for r in APFRAMEROWS[fid]: if ((not finp[r]) or (not np.isfinite(APDISTDIRECT[r])) or APDISTDIRECT[r] <= TOUT): continue if (APPX[r] < CENTERX) != (s == 'L'): continue gd = float(APGOALDL[r] if s == 'L' else APGOALDR[r]) verdict = '✓ гейт' if gd <= GATEGOALM else '✂ гейт' outs.append(f"dist={APDISTDIRECT[r]:.2f} x={APPX[r]:.0f} " f"доворот={gd:.1f}м {verdict}") if outs: print(f" Кр1-выбросы[{s}]: " + '; '.join(outs)) for s in ('L', 'R'): first = next((f for f in fids if GKCFRAMES[f]['kr4'][s]['ok']), None) print(f" Первый ok-кадр Кр4[{s}]: f{_first}"

  • ("" if first is None else f" (d={GKCFRAMES[first]['kr4'][s]['dist']:.2f}, " f"goal={GKCFRAMES[first]['kr4'][s]['goaldist']:.1f}м)"))

if callable(globals().get('freememory')): freememory()

print() print(f"✅ Ячейка {CELLTAG} готова ({time.perfcounter() - t00:.1f} c). Экспорт: " "APDISTDIRECT, APGOALDL/DR, GKCFRAMES (kr4 с гейтом), константы ТЗ " "(TOUT=2.5, TCROWD=2.2, TMATCH=1.2, GATEGOALM=30, SEL*, CROWDMIN, " "VMAXGK, RBASE, KEXPAND, RCAP, CMAX, CENTERX, WPOS/WDIR/WCOL, " "GAX/PAX/GAY/PAY, GOALCENTER).") print(" Для 29G также проброшены: AP* (+APDISTDIRECT, APGOALDL/DR), " "APFRAMEROWS, APROW, VLPOS/VLGIDX/VLEMB, GKPFRAMES, CENTS, GRAYTHRESH, " "PROTO, FTAFRAMES.") print(" ⚠️ D2 не перезапускать — его проверку заменяет блок «🔧 Проверка исправления».")

@title 29G. Этап 1–2 GK-блока: селекция по окнам появления + прямой трекинг

#

Вход: данные 28G v3 (память | кэши). Выход: gktracksfwd.json + глобали.

Этап 1 (селекция): онлайн-«почки» (цепочки незакреплённых не-ref детекций,

сшивка d<RLINK, gap<=LINKGAP). Окно почки = SEL_WINDOW кадров от её первой

детекции. При закрытии окна — Кр1/Кр2/Кр3/Кр4 (все — только на своей половине,

Опр. 5; Кр1/Кр4 — с гейтом GATEGOALM до центра своих ворот; защиты:

Кр1 — медиана окна >= TOUT и >= SELMINFRAMES выбросов; Кр4 — >= SELMIN_FRAMES

субъектных кадров и медиана субъектных DISTTEAM > TCROWD; Кр2 — >= SELMINFRAMES

кадров; Кр3 — streak >= SEL_STREAK). Guard'ы: ref исключены; сторона занята;

всего >= 2. Per-side: <= 1 новый трек на момент закрытия (сильнейший по

Кр2 > Кр3 > Кр4 > Кр1 > med_d). Результат трека: сторона; режим A/B по

DISTTEAM(EMA-прототипа окна) vs TOUT; команда — голоса FTA окна

(гистерезис, серые не голосуют; иначе raw-цвет к центроидам, team_soft);

Kalman [x,y,vx,vy] — медианы окна; цветовой прототип EMA окна.

Этап 2 (трекинг): предсказание Kalman; кандидаты = незакреплённые не-ref

детекции в радиусе R, прошедшие цветовой фильтр режима (Опр. 7, T_MATCH):

A: DISTTEAM > TMATCH или DISTGK <= TMATCH;

B: DISTGK <= TMATCH или FTA-команда совпадает или серый.

R = RBASE + |v|·dt (активный); при потере veff <- min(veff·KEXPAND,

VMAXGK) за кадр, R = RBASE + veff·N·dt, cap RCAP. Cost = W_POS·(d/R)

+ WDIR·(1-cos)/2 + WCOL·DISTGK/TMATCH; назначение — Венгр, порог C_MAX.

Потеря: трек живёт до конца клипа (прогноз с демпфированием скорости).

Инварианты I1–I4 проверяются. I2: новые треки только в окнах появления.

Без чтения видео; numpy; секунды работы.

import os, json, time import numpy as np from collections import Counter from scipy.optimize import linearsumassignment

t00 = time.perfcounter() CELLTAG = '29G'

================== КОНСТАНТЫ ТЗ (из 28G v3) ==================

TOUT = float(globals().get('TOUT', 2.5)) TCROWD = float(globals().get('TCROWD', 2.2)) TMATCH = float(globals().get('TMATCH', 1.2)) GATEGOALM = float(globals().get('GATEGOALM', 30.0)) SELWINDOW = int(globals().get('SELWINDOW', 6)) SELSTREAK = int(globals().get('SELSTREAK', 4)) SELMINFRAMES = int(globals().get('SELMINFRAMES', 2)) CROWDMIN = int(globals().get('CROWDMIN', 21)) VMAXGK = float(globals().get('VMAXGK', 10.0)) RBASE = float(globals().get('RBASE', 2.5)) KEXPAND = float(globals().get('KEXPAND', 1.15)) RCAP = float(globals().get('RCAP', 40.0)) CMAX = float(globals().get('CMAX', 1.2)) CENTERX = float(globals().get('CENTERX', 52.5)) WPOS, WDIR, WCOL = (float(globals().get('WPOS', 0.5)), float(globals().get('WDIR', 0.2)), float(globals().get('WCOL', 0.3))) GAY = tuple(globals().get('GAY', (24.84, 43.16))) PAY = tuple(globals().get('PAY', (13.84, 54.16))) GAX = globals().get('GAX') or {'L': (-1.0, 5.5), 'R': (99.5, 106.0)} PAX = globals().get('PAX') or {'L': (-1.0, 16.5), 'R': (88.5, 106.0)} GOALCENTER = globals().get('GOALCENTER') or {'L': (0.0, 34.0), 'R': (105.0, 34.0)}

--- локальные параметры 29G ---

RLINK, LINKGAP = 2.0, 2 # сшивка почек: радиус (м), пропуск (кадры) EMACOLORALPHA = 0.4 # EMA цветового прототипа KFQ, KFR = 6.0, 0.20 # Kalman: шум процесса (м/с^2), измерения (м^2) DAMPLOST = 0.95 # демпфирование скорости за кадр потери DIRMINSPEED = 0.3 # м/с: ниже — нейтральная dir-компонента (0.5) NOECOLCOST = 1.5 # штраф цвета в cost при NaN E TEAMHYST, TEAMMIN_VOTES = 0.6, 3

CLSGK = int(globals().get('CLSGK', 1)) CLSREF = int(globals().get('CLSREF', 3)) CACHEDIR = str(globals().get('CACHEDIR', '/content/cache')) OUTPUTDIR = str(globals().get('OUTPUTDIR', '/content/output')) FWDPATH = os.path.join(OUTPUTDIR, 'gktracksfwd.json')

================== 1. Данные (память 28G | кэши) ==================

need = ['APGIDX', 'APFID', 'APCLS', 'APCONF', 'APX1', 'APY1', 'APX2', 'APY2', 'APPX', 'APPY', 'APPROJ', 'APTEAM', 'APCOLOR12', 'APDISTDIRECT'] if all(v in globals() and globals()[v] is not None for v in need): APGIDX = globals()['APGIDX']; APFID = globals()['APFID'] APCLS = globals()['APCLS']; APCONF = globals()['APCONF'] APX1 = globals()['APX1']; APY1 = globals()['APY1'] APX2 = globals()['APX2']; APY2 = globals()['APY2'] APPX = globals()['APPX']; APPY = globals()['APPY']; APPROJ = globals()['APPROJ'] APTEAM = globals()['APTEAM']; APCOLOR12 = globals()['APCOLOR12'] dd = np.asarray(globals()['APDISTDIRECT'], np.float32) APGOALDL = globals().get('APGOALDL'); APGOALDR = globals().get('APGOALDR') VLGIDX = globals().get('VLGIDX'); VLEMB = globals().get('VLEMB') SRC = 'memory28G' else: ap = os.path.join(CACHEDIR, 'appearancecache.npz') assert os.path.exists(ap), f"❌ {ap} — выполните 28 v4 + 28G." with np.load(ap) as z: APGIDX = z['gidx'].astype(np.int64); APFID = z['fid'].astype(np.int32) APCLS = z['cls'].astype(np.int8); APCONF = z['conf'].astype(np.float32) APX1 = z['x1'].astype(np.float32); APY1 = z['y1'].astype(np.float32) APX2 = z['x2'].astype(np.float32); APY2 = z['y2'].astype(np.float32) APPX = z['pitchx'].astype(np.float32); APPY = z['pitchy'].astype(np.float32) APPROJ = z['proj'].astype(bool); APTEAM = z['team'].astype(np.int8) APCOLOR12 = z['color12'].astype(np.float32) for k in ('distdirect', 'goaldl', 'goaldr'): assert k in z.files, f"❌ В кэше нет {k} — выполните 28G v3." dd = z['distdirect'].astype(np.float32) APGOALDL = z['goaldl'].astype(np.float32); APGOALDR = z['goaldr'].astype(np.float32) VLGIDX = (z['vlgidx'].astype(np.int64) if 'vlgidx' in z.files else np.zeros(0, np.int64)) VLEMB = (z['vlemb'].astype(np.float32) if 'vlemb' in z.files else np.zeros((0, 0), np.float32)) SRC = 'cache' NAP = len(APGIDX) if VLGIDX is None: VLGIDX = np.zeros(0, np.int64); VLEMB = np.zeros((0, 0), np.float32) VLPOS = {int(g): i for i, g in enumerate(VL_GIDX.tolist())}

if isinstance(globals().get('APFRAMEROWS'), dict) and globals()['APFRAMEROWS']: APFRAMEROWS = globals()['APFRAMEROWS'] else: o = np.argsort(APFID, kind='stable'); f = APFID[o] u, s = np.unique(f, returnindex=True); e = np.append(s[1:], len(f)) APFRAMEROWS = {int(u): np.sort(o[s:e]) for u, s, e in zip(u, s, e)} FIDS = sorted(APFRAMEROWS.keys()) FPS = float(globals().get('VIDEO_FPS', 25.0))

if isinstance(globals().get('GKCFRAMES'), dict) and globals()['GKCFRAMES']: GKC = globals()['GKCFRAMES'] else: c = os.path.join(CACHEDIR, 'gkcontextframes.json') assert os.path.exists(c), f"❌ {c} — выполните 28G." with open(c, encoding='utf-8') as f: GKC = {int(k): v for k, v in json.load(f).get('frames', {}).items()} GKP = {} if isinstance(globals().get('GKPFRAMES'), dict) and globals()['GKPFRAMES']: GKP = globals()['GKPFRAMES'] else: p = os.path.join(OUTPUTDIR, 'gkframeprimitives.json') if os.path.exists(p): with open(_p, encoding='utf-8') as f: GKP = {int(k): v for k, v in json.load(f).get('frames', {}).items()}

PROTO = globals().get('PROTO') if not isinstance(PROTO, dict): pp = os.path.join(OUTPUTDIR, 'teamprototypes.json') assert os.path.exists(pp), "❌ teamprototypes.json — выполните ячейку 22." with open(pp, encoding='utf-8') as f: PROTO = json.load(f) BLOCKS = PROTO.get('blocks', []) CENTS = np.asarray(PROTO['centroids_scaled'], np.float32)

def embed(c12): if not BLOCKS: return np.asarray(c12, np.float32) vs = [] for b in BLOCKS: s0, s1 = int(b['slice'][0]), int(b['slice'][1]) keep = np.asarray(b['keepdims'], int) part = c12[:, s0:s1][:, keep] vs.append(((part - np.asarray(b['mean'], np.float32)) / np.maximum(np.asarray(b['scale'], np.float32), 1e-6) ).astype(np.float32) / np.sqrt(max(1, len(keep)))) return np.hstack(vs) if len(vs) > 1 else vs[0] EALL = embed(APCOLOR12) assert EALL.shape[1] == CENTS.shape[1], "❌ D(color) != D(centroids)."

UNASSIGNED = 2 fmeta = globals().get('FTAMETA') if not (isinstance(fmeta, dict) and fmeta): fp = os.path.join(OUTPUTDIR, 'frameteamassignment.json') if os.path.exists(fp): with open(fp, encoding='utf-8') as f: fmeta = json.load(f).get('meta', {}) if isinstance(fmeta, dict): tn = {int(k): v for k, v in fmeta.get('teamnames', {}).items()} un = [k for k, v in tn.items() if v == 'unassigned'] if un: UNASSIGNED = int(un[0]) print(f"⚙️ {CELLTAG}: источник {SRC} | TMATCH={TMATCH} | гейт={GATEGOALM:.0f}м | " f"UNASSIGNED={UNASSIGNED} | кадров {len(FIDS)}")

================== 2. Kalman ==================

class KF: def _init(self, x, y, vx, vy): self.s = np.array([x, y, vx, vy], np.float64) self.P = np.diag([1.0, 1.0, 9.0, 9.0]) self.H = np.array([[1., 0, 0, 0], [0, 1., 0, 0]]) self.R = np.eye(2) * KFR def predict(self, dt, damp=1.0): dt = max(dt, 1e-3) self.s[2] = damp; self.s[3] = damp F = np.array([[1, 0, dt, 0], [0, 1, 0, dt], [0, 0, 1, 0], [0, 0, 0, 1]], np.float64) Q = KF_Q np.array([[dt4/4, 0, dt3/2, 0], [0, dt4/4, 0, dt3/2], [dt3/2, 0, dt2, 0], [0, dt3/2, 0, dt*2]], np.float64) self.s = F @ self.s self.P = F @ self.P @ F.T + Q def update(self, x, y): z = np.array([x, y], np.float64) S = self.H @ self.P @ self.H.T + self.R K = self.P @ self.H.T @ np.linalg.inv(S) self.s = self.s + K @ (z - self.H @ self.s) IKH = np.eye(4) - K @ self.H self.P = IKH @ self.P @ IKH.T + K @ self.R @ K.T @property def pos(self): return float(self.s[0]), float(self.s[1]) @property def vel(self): return float(self.s[2]), float(self.s[3])

================== 3. Трек GK ==================

class GKTrack: def _init(self, side, rowsw, crit, fidclose): fidsw = APFID[rowsw].astype(np.int64) order = np.argsort(fidsw, kind='stable') self.rows = list(np.asarray(rowsw)[order]) # закреплённые rows окна self.side = side self.selcrit = crit self.selfid = int(fidclose) self.fidstart = int(fidsw[order][0]) # EMA-прототип окна proto = None for r in self.rows: e = EALL[r] if not np.isfinite(e).all(): continue proto = e.copy() if proto is None else (1 - EMACOLORALPHA) proto + EMA_COLOR_ALPHA e assert proto is not None, "❌ Окно трека без валидного цвета" self.proto = proto.astype(np.float32) d0 = float(np.linalg.norm(self.proto - CENTS[0])) d1 = float(np.linalg.norm(self.proto - CENTS[1])) self.distteamproto = min(d0, d1) self.mode = 'A' if self.distteamproto > TOUT else 'B' # команда: голоса FTA окна -> raw-цвет votes = Counter(int(APTEAM[r]) for r in self.rows) v01 = {t: votes.get(t, 0) for t in (0, 1)} tot = v01[0] + v01[1] if tot >= TEAMMINVOTES and max(v01.values()) / tot >= TEAMHYST: self.team = 0 if v01[0] > v01[1] else 1 self.teamsoft = False else: self.team = 0 if d0 <= d1 else 1 self.teamsoft = True self.votes = Counter() for t in (0, 1): if v01[t]: self.votes[t] += v01[t] # Kalman: медианы окна px = float(np.nanmedian(APPX[self.rows])); py = float(np.nanmedian(APPY[self.rows])) sf = np.argsort(fidsw[order], kind='stable') rr = np.asarray(self.rows)[sf]; ff = fidsw[order][sf] vxs, vys = [], [] for a, b in zip(range(len(rr) - 1), range(1, len(rr))): dtf = (ff[b] - ff[a]) / FPS if dtf <= 0 or not (np.isfinite(APPX[rr[a]]) and np.isfinite(APPX[rr[b]])): continue vxs.append((APPX[rr[b]] - APPX[rr[a]]) / dtf) vys.append((APPY[rr[b]] - APPY[rr[a]]) / dtf) vx = float(np.median(vxs)) if vxs else 0.0 vy = float(np.median(vys)) if vys else 0.0 self.kf = KF(px, py, vx, vy) # состояние трекинга self.frames = [] # ассоциации self.loststreak = 0 self.totallost = 0 self.nrecoveries = 0 self.veff = None self.lastprocfid = self.fidstart self.lostlog = [] # (fidstartloss, fidrecovery, dur, R) self.lossopened = None for r in self.rows: # окна сразу в frames self.pushframe(int(APFID[r]), r) def pushframe(self, fid, r): e = EALL[r] dgk = (float(np.linalg.norm(e - self.proto)) if np.isfinite(e).all() else None) self.frames.append({'fid': int(fid), 'gidx': int(APGIDX[r]), 'bbox': [round(float(APX1[r]), 1), round(float(APY1[r]), 1), round(float(APX2[r]), 1), round(float(APY2[r]), 1)], 'px': round(float(APPX[r]), 2), 'py': round(float(APPY[r]), 2), 'vx': round(self.kf.vel[0], 2), 'vy': round(self.kf.vel[1], 2), 'dgk': (round(dgk, 3) if dgk is not None else None)}) def distgk(self, r): e = EALL[r] if not np.isfinite(e).all(): return None return float(np.linalg.norm(e - self.proto)) def colorfilter(self, r): """Опр. 7: цветовой фильтр кандидата по режиму.""" dt = float(dd[r]) if np.isfinite(dd[r]) else None dg = self.distgk(r) if self.mode == 'A': return (dt is not None and dt > TMATCH) or (dg is not None and dg <= TMATCH) if dg is not None and dg <= TMATCH: return True t = int(APTEAM[r]) return (t == self.team) or (t == UNASSIGNED)

================== 4. Основной проход ==================

tracks = [] # [GKTrack] sidebusy = {'L': False, 'R': False} owner = {} # row -> track idx sellog, trklog, conflicts = [], [], [] buds = [] # почки: {rows, firstfid, last_fid, px, py}

def med(v): v = [x for x in v if x is not None and np.isfinite(x)] return float(np.median(v)) if v else None

def evalbud(b): """Оценка критериев Кр1–Кр4 в окне почки. Возвращает dict или None (отказ-причина в 'reject').""" f0 = int(b['firstfid']); f1 = f0 + SELWINDOW - 1 rowsw = [r for r in b['rows'] if f0 <= int(APFID[r]) <= f1 and r not in owner] out = {'first': f0, 'close': f1, 'nw': len(rowsw)} if not rowsw: out['reject'] = 'empty'; return out pxs = [float(APPX[r]) for r in rowsw if np.isfinite(APPX[r])] if not pxs: out['reject'] = 'noproj'; return out mpx = float(np.median(pxs)) S = 'L' if mpx < CENTERX else 'R' out['side'] = S; out['medpx'] = round(mpx, 1) if sidebusy[S]: out['reject'] = 'sidebusy'; return out if len(tracks) >= 2: out['reject'] = 'limit2'; return out gset = set(int(APGIDX[r]) for r in rowsw) dds = [float(dd[r]) if np.isfinite(dd[r]) else None for r in rowsw] medd = med(dds) out['medd'] = round(medd, 2) if medd is not None else None goald = (float(np.hypot(mpx - GOALCENTER[S][0], float(np.median([float(APPY[r]) for r in rowsw if np.isfinite(APPY[r])])) - GOALCENTER[S][1])) if np.isfinite(np.median([float(APPY[r]) for r in rowsw if np.isfinite(APPY[r])])) else None) out['goald'] = round(goald, 1) if goald is not None else None # Кр1: медиана окна >= TOUT, >= SELMINFRAMES выбросов, гейт nout = sum(1 for v in dds if v is not None and v > TOUT) out['nout'] = nout out['kr1'] = bool(medd is not None and medd >= TOUT and nout >= SELMINFRAMES and goald is not None and goald <= GATEGOALM) # Кр4: субъектные кадры (kr4.ok и gidx в почке), медиана субъектных > TCROWD subjd = [] for f in range(f0, f1 + 1): k4 = GKC.get(f, {}).get('kr4', {}).get(S, {}) if k4.get('ok') and k4.get('gidx') in gset: r = None for q in rowsw: if int(APFID[q]) == f: r = q; break if r is not None and np.isfinite(dd[r]): subjd.append(float(dd[r])) out['k4n'] = len(subjd) out['med4'] = round(float(np.median(subjd)), 2) if subjd else None out['kr4'] = bool(len(subjd) >= SELMINFRAMES and bool(subjd) and float(np.median(subjd)) > TCROWD) # Кр2: visga ∧ GA solo ∧ solo==closest ∧ gidx в почке; >= SELMINFRAMES кадров n2 = 0 for f in range(f0, f1 + 1): gk, gc = GKP.get(f, {}), GKC.get(f, {}) solo = gc.get('gasologidx', {}).get(S) cl = gc.get('closest', {}).get(S) if (gk.get('visga', {}).get(S, False) and solo is not None and cl is not None and int(solo) == int(cl['gidx']) and int(solo) in gset): n2 += 1 out['n2'] = n2 out['kr2'] = bool(n2 >= SELMINFRAMES) # Кр3: vispa ∧ PA solo ∧ ==closest ∧ в почке; streak >= SELSTREAK run = best3 = 0 for f in range(f0, f1 + 1): gk, gc = GKP.get(f, {}), GKC.get(f, {}) solo = gc.get('pasologidx', {}).get(S) cl = gc.get('closest', {}).get(S) hit = (gk.get('vispa', {}).get(S, False) and solo is not None and cl is not None and int(solo) == int(cl['gidx']) and int(solo) in gset) run = run + 1 if hit else 0 best3 = max(best3, run) out['streak3'] = best3 out['kr3'] = bool(best3 >= SELSTREAK) out['confirmed'] = out['kr1'] or out['kr2'] or out['kr3'] or out['kr4'] if not out['confirmed']: out['reject'] = 'not_confirmed' return out

def budstrength(e): return (int(e.get('kr2', False)), int(e.get('kr3', False)), min(int(e.get('k4n', 0)), 6), int(e.get('kr1', False)), (e.get('medd') or 0.0))

for fid in FIDS: fid = int(fid) rows = APFRAMEROWS.get(fid, np.zeros(0, np.int64)) # --- (а) обновление почек --- free = [int(r) for r in rows if int(APCLS[r]) != CLSREF and int(r) not in owner and APPROJ[r] and np.isfinite(APPX[r]) and np.isfinite(APPY[r])] for r in sorted(free, key=lambda q: -APCONF[q]): best, bd = None, RLINK for b in buds: if fid - b['lastfid'] > LINKGAP or fid < b['lastfid']: continue d = float(np.hypot(APPX[r] - b['px'], APPY[r] - b['py'])) if d < bd: bd, best = d, b if best is not None: best['rows'].append(r); best['lastfid'] = fid best['px'] = float(APPX[r]); best['py'] = float(APPY[r]) else: buds.append({'rows': [r], 'firstfid': fid, 'lastfid': fid, 'px': float(APPX[r]), 'py': float(APPY[r])}) # --- (б) закрытие окон почек: селекция (per-side <= 1 на момент) --- closing = [b for b in buds if fid >= b['firstfid'] + SELWINDOW - 1] if closing: evals = [evalbud(b) for b in closing] taken = {} for b, e in zip(closing, evals): if e.get('reject'): sellog.append({**e, 'gidxs': [int(APGIDX[r]) for r in b['rows'][:8]]}) continue if e['side'] in taken: # гонка одной стороны на этом fid sellog.append({**e, 'reject': 'raced', 'gidxs': [int(APGIDX[r]) for r in b['rows'][:8]]}) continue taken[e['side']] = (b, e) for S, (b, e) in taken.items(): trk = GKTrack(S, [r for r in b['rows'] if b['firstfid'] <= int(APFID[r]) <= e['close'] and int(r) not in owner], [k for k in ('Кр1', 'Кр2', 'Кр3', 'Кр4') if {'Кр1': e['kr1'], 'Кр2': e['kr2'], 'Кр3': e['kr3'], 'Кр4': e['kr4']}[k]], fid) if not trk.rows: sellog.append({**e, 'reject': 'emptyafterowner'}) continue tix = len(tracks) tracks.append(trk) for r in trk.rows: owner[int(r)] = tix sidebusy[S] = True sellog.append({**e, 'created': True, 'trackix': tix, 'team': trk.team, 'teamsoft': trk.teamsoft, 'mode': trk.mode, 'distteamproto': round(trk.distteamproto, 3), 'crit': trk.selcrit, 'gidxs': [int(APGIDX[r]) for r in trk.rows]}) buds = [b for b in buds if b not in closing] # --- (в) трекинг --- if tracks: freerows = [int(r) for r in rows if int(APCLS[r]) != CLSREF and int(r) not in owner and APPROJ[r] and np.isfinite(APPX[r]) and np.isfinite(APPY[r])] candspertrk, candall = [], [] for trk in tracks: dt = (fid - trk.lastprocfid) / FPS # радиус if trk.loststreak == 0: vabs = float(np.hypot(*trk.kf.vel)) R = RBASE + vabs * max(dt, 1e-3) trk.kf.predict(dt) else: basev = trk.veff if trk.veff is not None else float(np.hypot(trk.kf.vel)) trk.v_eff = min(base_v KEXPAND, VMAXGK) N = trk.loststreak + 1 R = min(RBASE + trk.veff * N * max(dt, 1e-3), RCAP) trk.kf.predict(dt, damp=DAMPLOST) trk.R = R prx, pry = trk.kf.pos vv = np.array(trk.kf.vel, np.float64) vnorm = float(np.hypot(*vv)) lst = [] for r in freerows: d = float(np.hypot(APPX[r] - prx, APPY[r] - pry)) if d > R or d < 1e-9: continue if not trk.colorfilter(r): continue dg = trk.distgk(r) col = (dg / TMATCH) if dg is not None else NOECOLCOST if vnorm >= DIRMINSPEED: u = np.array([APPX[r] - prx, APPY[r] - pry], np.float64) / d cosv = float(np.dot(vv / vnorm, u)) dirc = (1.0 - cosv) / 2.0 else: dirc = 0.5 cost = WPOS * (d / R) + WDIR dirc + W_COL col lst.append((r, cost, d)) candspertrk.append(lst) for r, c, d in lst: if r not in candall: candall.append(r) trk.lastprocfid = fid # конфликт: кандидат у >= 2 треков if len(tracks) > 1 and candall: cc = Counter() for lst in candspertrk: for r, c, d in lst: cc[r] += 1 for r, n in cc.items(): if n > 1: conflicts.append({'fid': fid, 'gidx': int(APGIDX[r]), 'ntracks': n}) # Венгр assigned = {} if candall: M = np.full((len(tracks), len(candall)), 1e6, np.float64) for ti, lst in enumerate(candspertrk): for r, c, d in lst: M[ti, candall.index(r)] = c ri, ci = linearsumassignment(M) for a, b in zip(ri, ci): if M[a, b] <= CMAX: assigned[a] = candall[b] for ti, trk in enumerate(tracks): if ti in assigned: r = assigned[ti] trk.kf.update(float(APPX[r]), float(APPY[r])) e = EALL[r] if np.isfinite(e).all(): trk.proto = ((1 - EMACOLOR_ALPHA) * trk.proto

  • EMACOLORALPHA e).astype(np.float32) t = int(AP_TEAM[r]) if t in (0, 1): trk.votes[t] += 1 owner[int(r)] = ti trk._push_frame(fid, r) if trk.lost_streak > 0: trk.n_recoveries += 1 dur = trk.lost_streak trk.lost_log.append({'fid_from': trk._loss_opened, 'fid_to': fid, 'dur': int(dur), 'R': round(trk._R, 2)}) trk.lost_streak = 0 trk.v_eff = None else: if trk.lost_streak == 0: trk._loss_opened = fid trk.v_eff = float(np.hypot(trk.kf.vel)) trk.loststreak += 1 trk.totallost += 1

================== 5. Инварианты I1–I4 ==================

assert len(tracks) <= 2, "❌ I1: > 2 треков" sides = [t.side for t in tracks] assert len(sides) == len(set(sides)), "❌ I1: две стороны совпали" for t in tracks: assert t.selfid <= t.fidstart + SELWINDOW - 1, "❌ I2: трек создан вне окна появления" assert all(int(APCLS[r]) != CLSREF for r in t.rows), "❌ I3: ref в треке" fidst = [f['fid'] for f in t.frames] assert len(fidst) == len(set(fidst)), "❌ I4: два назначения в одном кадре" assert len(owner) == len(set(owner.keys())), "❌ I4: дубль owner" allrows = [r for t in tracks for r in t.rows] assert len(allrows) == len(set(all_rows)), "❌ I4: детекция в двух треках" print(f"✅ Инварианты I1–I4: OK (треков {len(tracks)}, сторон занято {sides})")

================== 6. Диагностика ==================

print(f"\n🎭 Селекция: {len(sellog)} закрытых окон почек") rej = Counter(e.get('reject', 'created') for e in sellog) print(f" Исходы: {dict(rej)}") for e in sellog: if e.get('created'): print(f" ✅ СОЗДАН {('GK' + e['side'])}: окно f{e['first']}–{e['close']} " f"nw={e['nw']} | критерии: {'+'.join(e['crit'])} | " f"medd={e['medd']} nout={e['nout']} | Кр4: n={e['k4n']} med={e['med4']} | " f"Кр2 n={e['n2']} Кр3 str={e['streak3']} | goal={e['goald']}м | " f"режим {e['mode']} (protod={e['distteamproto']}) | " f"team {e['team']}{' (soft)' if e['teamsoft'] else ''}") elif e.get('reject') in ('sidebusy', 'limit2', 'raced'): print(f" ⛔ {e.get('reject')}: окно f{e['first']}–{e['close']} side={e.get('side')} " f"medd={e.get('medd')} nout={e.get('nout')} " f"Кр4 n={e.get('k4n')} Кр2 n={e.get('n2')} Кр3 str={e.get('streak3')}") if conflicts: print(f"\n⚔️ Конфликты Венгра (кандидат у >=2 треков): {len(conflicts)}") for c in conflicts[:10]: print(f" f{c['fid']} gidx={c['gidx']} ({c['n_tracks']} трека)") else: print("\n⚔️ Конфликтов Венгра нет")

================== 7. Сводка треков + приёмка 13.2 ==================

def trackstats(t): fr = t.frames fidst = sorted(f['fid'] for f in fr) px = np.array([f['px'] for f in fr]); py = np.array([f['py'] for f in fr]) pa = ((px >= PAX[t.side][0]) & (px <= PAX[t.side][1]) & (py >= PAY[0]) & (py <= PAY[1])) ga = ((px >= GAX[t.side][0]) & (px <= GAX[t.side][1]) & (py >= GAY[0]) & (py <= GAY[1])) third = ((px <= 35.0) if t.side == 'L' else (px >= 70.0)) dg = [f['dgk'] for f in fr if f['dgk'] is not None] return {'pashare': round(float(pa.mean()), 3) if len(fr) else 0.0, 'gashare': round(float(ga.mean()), 3) if len(fr) else 0.0, 'thirdshare': round(float(third.mean()), 3) if len(fr) else 0.0, 'nlost': int(t.totallost), 'nrecovered': int(t.nrecoveries), 'purityproxy': (round(float(np.mean(np.array(dg) <= T_MATCH)), 3) if dg else None)}

summary = [] print(f"\n📋 Сводка треков (прямой проход):") for t in tracks: st = trackstats(t) fidst = sorted(f['fid'] for f in t.frames) cov = len(fidst) / max(1, fidst[-1] - fidst[0] + 1) print(f" GK{t.side}: team {t.team}{' (soft)' if t.teamsoft else ''} | режим {t.mode} " f"(protod={t.distteamproto:.2f}) | селекция f{t.selfid} по {'+'.join(t.selcrit)} | " f"f{t.fidstart}..{fidst[-1] if fidst else '—'} | кадров {len(t.frames)} | " f"coverage(диапазон) {100*cov:.0f}%") print(f" голоса FTA (окно+трек): {dict(t.votes)} | потери: {t.totallost} кадров, " f"восстановлений {t.nrecoveries} | purity-proxy {st['purityproxy']} | " f"pashare {st['pashare']} third {st['thirdshare']}") for L in t.lostlog[:6]: print(f" потеря f{L['fidfrom']}..f{L['fidto']} ({L['dur']} к, R={L['R']}м)") vl = [VLPOS[int(APGIDX[r])] for r in t.rows if int(APGIDX[r]) in VLPOS] vlproto = (np.mean(VLEMB[vl], axis=0) if vl else None) summary.append({'side': t.side, 'team': int(t.team), 'teamsoft': bool(t.teamsoft), 'mode': t.mode, 'fidstart': t.fidstart, 'fidend': int(fidst[-1]) if fidst else None, 'selfid': t.selfid, 'selcrit': t.selcrit, 'distteamproto': round(float(t.distteamproto), 4), 'nframes': len(t.frames), 'protoe': t.proto.tolist(), 'vlproto': (vlproto.tolist() if vlproto is not None else None), 'stats': st, 'frames': t.frames, 'lostlog': t.lostlog})

приёмка 13.2

okn = len(tracks) == 2 tl = {t.side: t for t in tracks} okL = okn and tl['L'].mode == 'A' and tl['L'].team == 1 and tl['L'].fidstart <= 5 okR = okn and tl['R'].mode == 'B' and tl['R'].team == 0 and tl['R'].fidstart >= 150 okT = okn and tl['L'].team != tl['R'].team print(f"\n📋 Приёмка ТЗ 13.2: ровно 2 трека: {'✅' if okn else '⚠️'} | " f"GKL (A, team 1, f0+): {'✅' if okL else '⚠️'} | " f"GKR (B, team 0, f150+): {'✅' if okR else '⚠️'} | TL≠T_R: {'✅' if okT else '⚠️'}")

================== 8. Сохранение + глобали ==================

GKTRACKS = summary GKEXCLUDEDGIDX = set(int(APGIDX[r]) for t in tracks for r in t.rows) GKOWNERMAP = {int(APGIDX[r]): f"GK{t.side}" for t in tracks for r in t.rows} payload = {'meta': {'cell': CELLTAG, 'src': SRC, 'fps': FPS, 'params': {'TOUT': TOUT, 'TCROWD': TCROWD, 'TMATCH': TMATCH, 'GATEGOALM': GATEGOALM, 'SELWINDOW': SELWINDOW, 'SELSTREAK': SELSTREAK, 'SELMINFRAMES': SELMINFRAMES, 'CROWDMIN': CROWDMIN, 'VMAXGK': VMAXGK, 'RBASE': RBASE, 'KEXPAND': KEXPAND, 'RCAP': RCAP, 'CMAX': CMAX, 'WPOS': WPOS, 'WDIR': WDIR, 'WCOL': WCOL, 'RLINK': RLINK, 'LINKGAP': LINKGAP, 'EMACOLORALPHA': EMACOLORALPHA, 'KFQ': KFQ, 'KFR': KFR, 'DAMPLOST': DAMPLOST}, 'ntracks': len(tracks), 'seloutcomes': dict(rej), 'nconflicts': len(conflicts)}, 'tracks': summary, 'sellog': [{k: v for k, v in e.items()} for e in sellog], 'conflicts': conflicts} with open(FWDPATH, 'w', encoding='utf-8') as f: json.dump(payload, f, ensureascii=False, separators=(',', ':')) print(f"\n💾 {FWDPATH}") print(f"✅ {CELLTAG} готов ({time.perfcounter() - t00:.1f} c). Глобали: GKTRACKS, " "GKEXCLUDEDGIDX, GKOWNERMAP. Следующий шаг — 30G (обратный проход + слияние).")

@title 30G. Этап 3 GK-блока: обратный проход (окна исчезновения) + слияние + арбитраж

#

Вход: 29G (gktracksfwd.json | глобали) + данные 28G v3 (память | кэши).

[1] Обратный трекинг: та же логика Этапов 1–2 при обходе кадров по УБЫВАНИЮ fid.

Окна селекции обратного прохода = окна исчезновения: при обратном обходе

окно почки = [fidhi-SELWINDOW+1, fid_hi]; критерии Кр1–Кр4 те же

(kr4-флаги/видимость зон не зависят от направления; streak симметричен).

Стороны фиксированы прямым проходом (L/R); обратная селекция только

ПОДТВЕРЖДАЕТ сторону (guard side_confirmed; новые треки не создаются — I2).

Трекинг назад: Kalman с dt<0 (Q по |dt|), радиусы/потери/цвет-фильтр

режима — как в 29G; dir-компонента зеркальна (u = (pred-cand)/d).

[2] Слияние прямого и обратного назначений:

ядро = кадры, где оба прохода назначили одну детекцию (истина);

конфликтные кадры (разные gidx / только один проход) — арбитраж:

cost = позиция до сглаженной траектории ЯДРА (интерполяция внутри,

экстраполяция со скоростью края за пределами) + цвет до прототипа ядра

+ VL (cos) ; назначение — Венгр [треки × кандидаты + «никто»], порог

CMAXARB; жёсткие гейты ARBHARDM (позиция) и ARBHARDCOL (цвет).

Отклонённые назначения ВЫРЕЗАЮТСЯ — детекции возвращаются в пул полевых

(исправление ложного продолжения fwd GK_L после потери).

[3] Команда трека пересчитывается по ядру (голоса FTA, гистерезис; fallback

raw-цвет прототипа ядра, team_soft).

Без чтения видео; numpy; секунды. Инварианты I1–I4 проверяются.

import os, json, time import numpy as np from collections import Counter from scipy.optimize import linearsumassignment

t00 = time.perfcounter() CELLTAG = '30G'

================== КОНСТАНТЫ ТЗ (из 28G v3 / 29G) ==================

TOUT = float(globals().get('TOUT', 2.5)) TCROWD = float(globals().get('TCROWD', 2.2)) TMATCH = float(globals().get('TMATCH', 1.2)) GATEGOALM = float(globals().get('GATEGOALM', 30.0)) SELWINDOW = int(globals().get('SELWINDOW', 6)) SELSTREAK = int(globals().get('SELSTREAK', 4)) SELMINFRAMES = int(globals().get('SELMINFRAMES', 2)) CROWDMIN = int(globals().get('CROWDMIN', 21)) VMAXGK = float(globals().get('VMAXGK', 10.0)) RBASE = float(globals().get('RBASE', 2.5)) KEXPAND = float(globals().get('KEXPAND', 1.15)) RCAP = float(globals().get('RCAP', 40.0)) CMAX = float(globals().get('CMAX', 1.2)) CENTERX = float(globals().get('CENTERX', 52.5)) WPOS, WDIR, WCOL = (float(globals().get('WPOS', 0.5)), float(globals().get('WDIR', 0.2)), float(globals().get('WCOL', 0.3))) GAY = tuple(globals().get('GAY', (24.84, 43.16))) PAY = tuple(globals().get('PAY', (13.84, 54.16))) GAX = globals().get('GAX') or {'L': (-1.0, 5.5), 'R': (99.5, 106.0)} PAX = globals().get('PAX') or {'L': (-1.0, 16.5), 'R': (88.5, 106.0)} GOALCENTER = globals().get('GOALCENTER') or {'L': (0.0, 34.0), 'R': (105.0, 34.0)}

--- локальные параметры 29G (те же значения) ---

RLINK, LINKGAP = 2.0, 2 EMACOLORALPHA = 0.4 KFQ, KFR = 6.0, 0.20 DAMPLOST = 0.95 DIRMINSPEED = 0.3 NOECOLCOST = 1.5 TEAMHYST, TEAMMIN_VOTES = 0.6, 3

--- локальные параметры 30G (арбитраж/слияние) ---

ARBPOSGATEM = 8.0 # норма позиции в cost (d/8) ARBHARDM = 12.0 # жёсткий гейт: дальше 12 м от траектории ядра — отказ ARBHARDCOL = 3.0 # жёсткий гейт цвета к прототипу ядра CMAXARB = 1.2 # порог cost арбитража WARBPOS, WARBCOL, WARBVL = 0.5, 0.3, 0.2 SMOOTHMEDWIN = 5 # медианное сглаживание ядра EXTRAPCAP_M = 25.0 # потолок экстраполяции за границы ядра

CLSGK = int(globals().get('CLSGK', 1)) CLSREF = int(globals().get('CLSREF', 3)) CACHEDIR = str(globals().get('CACHEDIR', '/content/cache')) OUTPUTDIR = str(globals().get('OUTPUTDIR', '/content/output')) FWDPATH = os.path.join(OUTPUTDIR, 'gktracksfwd.json') MRGPATH = os.path.join(OUTPUTDIR, 'gktracksmerged.json')

================== 1. Данные (память | кэши) + fwd-треки ==================

need = ['APGIDX', 'APFID', 'APCLS', 'APCONF', 'APX1', 'APY1', 'APX2', 'APY2', 'APPX', 'APPY', 'APPROJ', 'APTEAM', 'APCOLOR12', 'APDISTDIRECT'] if all(v in globals() and globals()[v] is not None for v in need): APGIDX = globals()['APGIDX']; APFID = globals()['APFID'] APCLS = globals()['APCLS']; APCONF = globals()['APCONF'] APX1 = globals()['APX1']; APY1 = globals()['APY1'] APX2 = globals()['APX2']; APY2 = globals()['APY2'] APPX = globals()['APPX']; APPY = globals()['APPY']; APPROJ = globals()['APPROJ'] APTEAM = globals()['APTEAM']; APCOLOR12 = globals()['APCOLOR12'] dd = np.asarray(globals()['APDISTDIRECT'], np.float32) VLGIDX = globals().get('VLGIDX'); VLEMB = globals().get('VLEMB') SRC = 'memory28G' else: ap = os.path.join(CACHEDIR, 'appearancecache.npz') assert os.path.exists(ap), f"❌ {ap} — выполните 28 v4 + 28G." with np.load(ap) as z: APGIDX = z['gidx'].astype(np.int64); APFID = z['fid'].astype(np.int32) APCLS = z['cls'].astype(np.int8); APCONF = z['conf'].astype(np.float32) APX1 = z['x1'].astype(np.float32); APY1 = z['y1'].astype(np.float32) APX2 = z['x2'].astype(np.float32); APY2 = z['y2'].astype(np.float32) APPX = z['pitchx'].astype(np.float32); APPY = z['pitchy'].astype(np.float32) APPROJ = z['proj'].astype(bool); APTEAM = z['team'].astype(np.int8) APCOLOR12 = z['color12'].astype(np.float32) assert 'distdirect' in z.files, "❌ Нет distdirect — выполните 28G v3." dd = z['distdirect'].astype(np.float32) VLGIDX = (z['vlgidx'].astype(np.int64) if 'vlgidx' in z.files else np.zeros(0, np.int64)) VLEMB = (z['vlemb'].astype(np.float32) if 'vlemb' in z.files else np.zeros((0, 0), np.float32)) SRC = 'cache' NAP = len(APGIDX) if VLGIDX is None: VLGIDX = np.zeros(0, np.int64); VLEMB = np.zeros((0, 0), np.float32) VLPOS = {int(g): i for i, g in enumerate(VLGIDX.tolist())} APROW = {int(g): i for i, g in enumerate(APGIDX.tolist())}

if isinstance(globals().get('APFRAMEROWS'), dict) and globals()['APFRAMEROWS']: APFRAMEROWS = globals()['APFRAMEROWS'] else: o = np.argsort(APFID, kind='stable'); f = APFID[o] u, s = np.unique(f, returnindex=True); e = np.append(s[1:], len(f)) APFRAMEROWS = {int(u): np.sort(o[s:e]) for u, s, e in zip(u, s, e)} FIDS = sorted(APFRAMEROWS.keys()) FPS = float(globals().get('VIDEO_FPS', 25.0))

if isinstance(globals().get('GKCFRAMES'), dict) and globals()['GKCFRAMES']: GKC = globals()['GKCFRAMES'] else: c = os.path.join(CACHEDIR, 'gkcontextframes.json') assert os.path.exists(c), f"❌ {c} — выполните 28G." with open(c, encoding='utf-8') as f: GKC = {int(k): v for k, v in json.load(f).get('frames', {}).items()} GKP = {} if isinstance(globals().get('GKPFRAMES'), dict) and globals()['GKPFRAMES']: GKP = globals()['GKPFRAMES'] else: p = os.path.join(OUTPUTDIR, 'gkframeprimitives.json') if os.path.exists(p): with open(_p, encoding='utf-8') as f: GKP = {int(k): v for k, v in json.load(f).get('frames', {}).items()}

PROTO = globals().get('PROTO') if not isinstance(PROTO, dict): pp = os.path.join(OUTPUTDIR, 'teamprototypes.json') assert os.path.exists(pp), "❌ teamprototypes.json — выполните ячейку 22." with open(pp, encoding='utf-8') as f: PROTO = json.load(f) BLOCKS = PROTO.get('blocks', []) CENTS = np.asarray(PROTO['centroids_scaled'], np.float32)

def embed(c12): if not BLOCKS: return np.asarray(c12, np.float32) vs = [] for b in BLOCKS: s0, s1 = int(b['slice'][0]), int(b['slice'][1]) keep = np.asarray(b['keepdims'], int) part = c12[:, s0:s1][:, keep] vs.append(((part - np.asarray(b['mean'], np.float32)) / np.maximum(np.asarray(b['scale'], np.float32), 1e-6) ).astype(np.float32) / np.sqrt(max(1, len(keep)))) return np.hstack(vs) if len(vs) > 1 else vs[0] EALL = embed(APCOLOR12) assert EALL.shape[1] == CENTS.shape[1], "❌ D(color) != D(centroids)."

UNASSIGNED = 2 fmeta = globals().get('FTAMETA') if not (isinstance(fmeta, dict) and fmeta): fp = os.path.join(OUTPUTDIR, 'frameteamassignment.json') if os.path.exists(fp): with open(fp, encoding='utf-8') as f: fmeta = json.load(f).get('meta', {}) if isinstance(fmeta, dict): tn = {int(k): v for k, v in fmeta.get('teamnames', {}).items()} un = [k for k, v in tn.items() if v == 'unassigned'] if un: UNASSIGNED = int(_un[0])

--- fwd-треки 29G ---

if isinstance(globals().get('GKTRACKS'), list) and globals()['GKTRACKS']: FWD = globals()['GKTRACKS']; FWDSRC = 'memory29G' else: assert os.path.exists(FWDPATH), f"❌ {FWDPATH} не найден — выполните 29G." with open(FWDPATH, encoding='utf-8') as f: FWD = json.load(f)['tracks']; FWDSRC = 'json' assert len(FWD) in (1, 2), "❌ Ожидался 1–2 fwd-трека." fwdbyside = {t['side']: t for t in FWD} print(f"⚙️ {CELLTAG}: данные {SRC} | fwd {FWDSRC} | сторон: {sorted(fwdbyside)}")

def med(v): v = [x for x in v if x is not None and np.isfinite(x)] return float(np.median(v)) if v else None

================== 2. Kalman обратного прохода (dt<0, Q по |dt|) ==================

class KBwd: def _init(self, x, y, vx, vy): self.s = np.array([x, y, vx, vy], np.float64) self.P = np.diag([1.0, 1.0, 9.0, 9.0]) self.H = np.array([[1., 0, 0, 0], [0, 1., 0, 0]]) self.R = np.eye(2) * KFR def predict(self, dt, damp=1.0): a = abs(dt) self.s[2] = damp; self.s[3] = damp F = np.array([[1, 0, dt, 0], [0, 1, 0, dt], [0, 0, 1, 0], [0, 0, 0, 1]], np.float64) Q = KF_Q np.array([[a4/4, 0, a3/2np.sign(dt), 0], [0, a4/4, 0, a3/2np.sign(dt)], [a3/2np.sign(dt), 0, a2, 0], [0, a3/2np.sign(dt), 0, a*2]], np.float64) self.s = F @ self.s self.P = F @ self.P @ F.T + Q def update(self, x, y): z = np.array([x, y], np.float64) S = self.H @ self.P @ self.H.T + self.R K = self.P @ self.H.T @ np.linalg.inv(S) self.s = self.s + K @ (z - self.H @ self.s) IKH = np.eye(4) - K @ self.H self.P = IKH @ self.P @ IKH.T + K @ self.R @ K.T @property def pos(self): return float(self.s[0]), float(self.s[1]) @property def vel(self): return float(self.s[2]), float(self.s[3])

================== 3. Обратный трек GK ==================

class BwdGK: def _init(self, side, rowsw, fidhi): self.side = side fidsw = APFID[rowsw].astype(np.int64) order = np.argsort(fidsw, kind='stable') self.rows = list(np.asarray(rowsw)[order]) self.mode = fwdbyside[side]['mode'] self.team = int(fwdbyside[side]['team']) # для цвет-фильтра B proto = None for r in self.rows: e = EALL[r] if not np.isfinite(e).all(): continue proto = e.copy() if proto is None else (1 - EMACOLORALPHA) * proto + EMACOLORALPHA * e assert proto is not None, "❌ Окно bwd без валидного цвета" self.proto = proto.astype(np.float32) px = float(np.nanmedian(APPX[self.rows])); py = float(np.nanmedian(APPY[self.rows])) rr = np.asarray(self.rows); ff = fidsw[order] vxs, vys = [], [] for a, b in zip(range(len(rr) - 1), range(1, len(rr))): dtf = (ff[b] - ff[a]) / FPS if dtf <= 0 or not (np.isfinite(APPX[rr[a]]) and np.isfinite(APPX[rr[b]])): continue vxs.append((APPX[rr[b]] - APPX[rr[a]]) / dtf) vys.append((APPY[rr[b]] - APPY[rr[a]]) / dtf) self.kf = KBwd(px, py, float(np.median(vxs)) if vxs else 0.0, float(np.median(vys)) if vys else 0.0) self.frames = [] self.loststreak = 0 self.totallost = 0 self.nrecoveries = 0 self.veff = None self.lastprocfid = int(min(ff)) # нижняя граница окна (дальше идём вниз) self.selfid = int(fidhi) for r in self.rows: self.push(int(APFID[r]), r) def push(self, fid, r): e = EALL[r] dgk = (float(np.linalg.norm(e - self.proto)) if np.isfinite(e).all() else None) self.frames.append({'fid': int(fid), 'gidx': int(APGIDX[r]), 'bbox': [round(float(APX1[r]), 1), round(float(APY1[r]), 1), round(float(APX2[r]), 1), round(float(APY2[r]), 1)], 'px': round(float(APPX[r]), 2), 'py': round(float(APPY[r]), 2), 'vx': round(self.kf.vel[0], 2), 'vy': round(self.kf.vel[1], 2), 'dgk': (round(dgk, 3) if dgk is not None else None)}) def distgk(self, r): e = EALL[r] return float(np.linalg.norm(e - self.proto)) if np.isfinite(e).all() else None def colorfilter(self, r): dt = float(dd[r]) if np.isfinite(dd[r]) else None dg = self.distgk(r) if self.mode == 'A': return (dt is not None and dt > TMATCH) or (dg is not None and dg <= TMATCH) if dg is not None and dg <= TMATCH: return True t = int(APTEAM[r]) return (t == self.team) or (t == UNASSIGNED)

================== 4. Обратный проход ==================

bwdtracks = {} # side -> BwdGK ownerbwd = {} # row -> side buds = [] sellogbwd = []

def evalbudbwd(b): hi = int(b['fidhi']) lo = hi - SELWINDOW + 1 rowsw = [r for r in b['rows'] if lo <= int(APFID[r]) <= hi and int(r) not in ownerbwd] out = {'hi': hi, 'lo': lo, 'nw': len(rowsw)} if not rowsw: out['reject'] = 'empty'; return out pxs = [float(APPX[r]) for r in rowsw if np.isfinite(APPX[r])] if not pxs: out['reject'] = 'noproj'; return out mpx = float(np.median(pxs)) S = 'L' if mpx < CENTERX else 'R' out['side'] = S if S in bwdtracks: out['reject'] = 'sideconfirmed'; return out if S not in fwdbyside: out['reject'] = 'nofwdside'; return out gset = set(int(APGIDX[r]) for r in rowsw) dds = [float(dd[r]) if np.isfinite(dd[r]) else None for r in rowsw] medd = med(dds) out['medd'] = round(medd, 2) if medd is not None else None pys = [float(APPY[r]) for r in rowsw if np.isfinite(APPY[r])] mpy = float(np.median(pys)) if pys else None goald = (float(np.hypot(mpx - GOALCENTER[S][0], mpy - GOALCENTER[S][1])) if mpy is not None else None) out['goald'] = round(goald, 1) if goald is not None else None nout = sum(1 for v in dds if v is not None and v > TOUT) out['nout'] = nout out['kr1'] = bool(medd is not None and medd >= TOUT and nout >= SELMINFRAMES and goald is not None and goald <= GATEGOALM) subjd = [] for f in range(lo, hi + 1): k4 = GKC.get(f, {}).get('kr4', {}).get(S, {}) if k4.get('ok') and k4.get('gidx') in gset: for q in rowsw: if int(APFID[q]) == f and np.isfinite(dd[q]): subjd.append(float(dd[q])) out['k4n'] = len(subjd) out['med4'] = round(float(np.median(subjd)), 2) if subjd else None out['kr4'] = bool(len(subjd) >= SELMINFRAMES and bool(subjd) and float(np.median(subjd)) > TCROWD) n2 = 0 for f in range(lo, hi + 1): gk, gc = GKP.get(f, {}), GKC.get(f, {}) solo = gc.get('gasologidx', {}).get(S) cl = gc.get('closest', {}).get(S) if (gk.get('visga', {}).get(S, False) and solo is not None and cl is not None and int(solo) == int(cl['gidx']) and int(solo) in gset): n2 += 1 out['n2'] = n2 out['kr2'] = bool(n2 >= SELMINFRAMES) run = best3 = 0 for f in range(lo, hi + 1): gk, gc = GKP.get(f, {}), GKC.get(f, {}) solo = gc.get('pasologidx', {}).get(S) cl = gc.get('closest', {}).get(S) hit = (gk.get('vispa', {}).get(S, False) and solo is not None and cl is not None and int(solo) == int(cl['gidx']) and int(solo) in gset) run = run + 1 if hit else 0 best3 = max(best3, run) out['streak3'] = best3 out['kr3'] = bool(best3 >= SELSTREAK) out['confirmed'] = out['kr1'] or out['kr2'] or out['kr3'] or out['kr4'] if not out['confirmed']: out['reject'] = 'notconfirmed' return out

def budstrength(e): return (int(e.get('kr2', False)), int(e.get('kr3', False)), min(int(e.get('k4n', 0)), 6), int(e.get('kr1', False)), (e.get('medd') or 0.0))

for fid in reversed(FIDS): fid = int(fid) rows = APFRAMEROWS.get(fid, np.zeros(0, np.int64)) free = [int(r) for r in rows if int(APCLS[r]) != CLSREF and int(r) not in ownerbwd and APPROJ[r] and np.isfinite(APPX[r]) and np.isfinite(APPY[r])] for r in sorted(free, key=lambda q: -APCONF[q]): best, bd = None, RLINK for b in buds: if b['lastfid'] - fid > LINKGAP or fid > b['lastfid']: continue d = float(np.hypot(APPX[r] - b['px'], APPY[r] - b['py'])) if d < bd: bd, best = d, b if best is not None: best['rows'].append(r); best['lastfid'] = fid best['px'] = float(APPX[r]); best['py'] = float(APPY[r]) else: buds.append({'rows': [r], 'fidhi': fid, 'lastfid': fid, 'px': float(APPX[r]), 'py': float(APPY[r])}) # закрытие окон (по убыванию): окно [fidhi-SELWINDOW+1, fidhi] closing = [b for b in buds if b['fidhi'] - fid >= SELWINDOW - 1] if closing: evals = [evalbudbwd(b) for b in closing] taken = {} for b, e in zip(closing, evals): if e.get('reject'): sellogbwd.append({**e, 'gidxs': [int(APGIDX[r]) for r in b['rows'][:8]]}) continue if e['side'] in taken: sellogbwd.append({e, 'reject': 'raced_bwd', 'gidxs': [int(AP_GIDX[r]) for r in b['rows'][:8]]}) continue taken[e['side']] = (b, e) for S, (b, e) in taken.items(): trk = BwdGK(S, [r for r in b['rows'] if e['lo'] <= int(AP_FID[r]) <= e['hi'] and int(r) not in owner_bwd], e['hi']) if not trk.rows: sel_log_bwd.append({e, 'reject': 'emptyafterowner'}) continue bwdtracks[S] = trk for r in trk.rows: ownerbwd[int(r)] = S sellogbwd.append({*e, 'created': True, 'mode': trk.mode, 'crit': [k for k in ('Кр1', 'Кр2', 'Кр3', 'Кр4') if {'Кр1': e['kr1'], 'Кр2': e['kr2'], 'Кр3': e['kr3'], 'Кр4': e['kr4']}[k]]}) buds = [b for b in buds if b not in closing] # трекинг подтверждённых треков (назад) if bwd_tracks: free_rows = [int(r) for r in rows if int(AP_CLS[r]) != CLS_REF and int(r) not in owner_bwd and AP_PROJ[r] and np.isfinite(AP_PX[r]) and np.isfinite(AP_PY[r])] sides_act = sorted(bwd_tracks.keys()) cands_per, cand_all = [], [] for S in sides_act: trk = bwd_tracks[S] dt = (fid - trk.last_proc_fid) / FPS # < 0 if trk.lost_streak == 0: v_abs = float(np.hypot(trk.kf.vel)) R = RBASE + vabs abs(dt) trk.kf.predict(dt) else: base_v = trk.v_eff if trk.v_eff is not None else float(np.hypot(trk.kf.vel)) trk.veff = min(basev K_EXPAND, VMAX_GK) N = trk.lost_streak + 1 R = min(R_BASE + trk.v_eff N abs(dt), R_CAP) trk.kf.predict(dt, damp=DAMP_LOST) trk._R = R prx, pry = trk.kf.pos vv = np.array(trk.kf.vel, np.float64) v_norm = float(np.hypot(vv)) lst = [] for r in freerows: d = float(np.hypot(APPX[r] - prx, APPY[r] - pry)) if d > R or d < 1e-9: continue if not trk.colorfilter(r): continue dg = trk.distgk(r) col = (dg / TMATCH) if dg is not None else NOECOLCOST if vnorm >= DIRMINSPEED: # зеркально fwd: вектор ОТ кандидата К прогнозу u = np.array([prx - APPX[r], pry - APPY[r]], np.float64) / d cosv = float(np.dot(vv / vnorm, u)) dirc = (1.0 - cosv) / 2.0 else: dirc = 0.5 cost = WPOS (d / R) + W_DIR dirc + WCOL * col lst.append((r, cost, d)) candsper.append(lst) for r, c, d in lst: if r not in candall: candall.append(r) trk.lastprocfid = fid assigned = {} if candall: M = np.full((len(sidesact), len(candall)), 1e6, np.float64) for ti, lst in enumerate(candsper): for r, c, d in lst: M[ti, candall.index(r)] = c ri, ci = linearsumassignment(M) for a, b in zip(ri, ci): if M[a, b] <= CMAX: assigned[a] = candall[b] for ti, S in enumerate(sidesact): trk = bwdtracks[S] if ti in assigned: r = assigned[ti] trk.kf.update(float(APPX[r]), float(APPY[r])) e = EALL[r] if np.isfinite(e).all(): trk.proto = ((1 - EMACOLORALPHA) * trk.proto

  • EMACOLORALPHA e).astype(np.float32) owner_bwd[int(r)] = S trk._push(fid, r) if trk.lost_streak > 0: trk.n_recoveries += 1 trk.lost_streak = 0 trk.v_eff = None else: if trk.lost_streak == 0: trk.v_eff = float(np.hypot(trk.kf.vel)) trk.loststreak += 1 trk.totallost += 1

================== 5. Слияние: ядро + арбитраж ==================

fwdmap = {S: {int(f['fid']): f for f in t['frames']} for S, t in fwdbyside.items()} bwdmap = {S: {int(f['fid']): f for f in t.frames} for S, t in bwd_tracks.items()}

core = {} for S in fwdmap: core[S] = {} for fid, fr in fwdmap[S].items(): b = bwd_map.get(S, {}).get(fid) if b is not None and int(b['gidx']) == int(fr['gidx']): core[S][fid] = fr

def buildcoreobjects(S): """Прототип ядра, VL-прототип ядра, сглаженная траектория ядра.""" fidsc = sorted(core[S].keys()) if len(fidsc) >= 2: rowsc = [APROW[int(core[S][f]['gidx'])] for f in fidsc] basef, basex, basey = fidsc, APPX[rowsc], APPY[rowsc] else: ffs = sorted(fwdmap[S].keys()) rowsf = [APROW[int(fwdmap[S][f]['gidx'])] for f in ffs] basef, basex, basey = ffs, APPX[rowsf], APPY[rowsf] # медианное сглаживание k = min(SMOOTHMEDWIN, len(basex) if len(basex) % 2 == 1 else len(basex) - 1) if k >= 3: sx = np.copy(basex); sy = np.copy(basey) h = k // 2 for i in range(len(basex)): a, b = max(0, i - h), min(len(basex), i + h + 1) sx[i] = float(np.median(basex[a:b])); sy[i] = float(np.median(basey[a:b])) else: sx, sy = np.copy(basex), np.copy(basey) # скорость краёв (м/кадр) для экстраполяции def edgeslope(arr): n = min(5, len(arr) - 1) if n < 1: return 0.0 return float(np.median(np.diff(arr[-n - 1:])) ) def edgeslopelo(arr): n = min(5, len(arr) - 1) if n < 1: return 0.0 return float(np.median(np.diff(arr[:n + 1]))) vxhi, vyhi = edgeslope(sx), edgeslope(sy) vxlo, vylo = edgeslopelo(sx), edgeslopelo(sy) f0c, f1c = basef[0], basef[-1] x0c, y0c, x1c, y1c = sx[0], sy[0], sx[-1], sy[-1] def smoothpos(fid): if f0c <= fid <= f1c: return (float(np.interp(fid, basef, sx)), float(np.interp(fid, basef, sy))) if fid > f1c: dfr = fid - f1c return (float(np.clip(x1c + vxhi dfr, x1c - EXTRAP_CAP_M, x1c + EXTRAP_CAP_M)), float(np.clip(y1c + vy_hi dfr, y1c - EXTRAPCAPM, y1c + EXTRAPCAPM))) dfr = f0c - fid return (float(np.clip(x0c - vxlo * dfr, x0c - EXTRAPCAPM, x0c + EXTRAPCAPM)), float(np.clip(y0c - vylo * dfr, y0c - EXTRAPCAPM, y0c + EXTRAPCAPM))) # прототипы if len(fidsc) >= 2: rowsc = [APROW[int(core[S][f]['gidx'])] for f in fidsc] Ec = EALL[rowsc] protocore = Ec[np.isfinite(Ec).all(1)].mean(axis=0).astype(np.float32) else: protocore = np.asarray(fwdbyside[S]['protoe'], np.float32) vlrows = [VLPOS[int(core[S][f]['gidx'])] for f in fidsc if int(core[S][f]['gidx']) in VLPOS] vlproto = None if vlrows: vlproto = VLEMB[vlrows].mean(axis=0) vlproto = vlproto / max(1e-6, float(np.linalg.norm(vlproto))) return smoothpos, protocore, vlproto, fids_c

sideobjs = {S: buildcoreobjects(S) for S in fwdmap}

--- конфликтные кадры и арбитраж ---

conflictfids = sorted(set().union(*[ (set(fwdmap[S].keys()) | set(bwdmap.get(S, {}).keys())) - set(core[S].keys()) for S in fwdmap])) sidesorder = sorted(fwdmap.keys()) arblog, cutlog = [], [] mergedframes = {S: {} for S in fwdmap}

for fid in conflictfids: # кандидаты: уникальные gidx из назначений fwd/bwd всех сторон candgidx, candsrc = [], {} for S in fwdmap: fr = fwdmap[S].get(fid) if fr is not None: g = int(fr['gidx']) if g not in candsrc: candgidx.append(g); candsrc[g] = (S, 'fwd', fr) bw = bwdmap.get(S, {}).get(fid) if bw is not None and not (fr is not None and int(bw['gidx']) == int(fr['gidx'])): g = int(bw['gidx']) if g not in candsrc: candgidx.append(g); candsrc[g] = (S, 'bwd', bw) if not candgidx: continue nc = len(candgidx) M = np.full((len(sidesorder), nc + 1), 1e6, np.float64) # + столбец «никто» M[:, nc] = CMAXARB 0.999 for si, S in enumerate(sides_order): smooth_pos, proto_core, vl_proto, _ = side_objs[S] spx, spy = smooth_pos(fid) for ci, g in enumerate(cand_gidx): r = AP_ROW[g] if int(AP_CLS[r]) == CLS_REF: continue d = float(np.hypot(AP_PX[r] - spx, AP_PY[r] - spy)) if d > ARB_HARD_M: continue e = E_ALL[r] dgk = (float(np.linalg.norm(e - proto_core)) if np.isfinite(e).all() else None) if dgk is None or dgk > ARB_HARD_COL: continue vlterm = 0.5 if vl_proto is not None and g in VL_POS: ve = VL_EMB[VL_POS[g]] vn = float(np.linalg.norm(ve)) if vn > 1e-6: vlterm = (1.0 - float(np.dot(ve / vn, vl_proto))) / 2.0 cost = (W_ARB_POS (d / ARBPOSGATE_M)

  • WARBCOL * (dgk / T_MATCH)
  • WARBVL vlterm) if cost <= C_MAX_ARB: M[si, ci] = cost ri, ci = linear_sum_assignment(M) for a, b in zip(ri, ci): if b == n_c or M[a, b] >= 1e6: continue S = sides_order[a] g = cand_gidx[b] cS, cpass, crec = cand_src[g] if cS != S: continue # кандидат чужой стороны merged_frames[S][fid] = {*crec, 'src': 'arb', 'pass': cpass} arblog.append({'fid': fid, 'side': S, 'gidx': g, 'pass': cpass, 'cost': round(float(M[a, b]), 3)}) for g in candgidx: Sg, pg, recg = candsrc[g] if fid not in mergedframes[Sg] or mergedframes[Sg][fid]['gidx'] != g: r = APROW[g] smoothpos, , , = sideobjs[Sg] spx, spy = smoothpos(fid) cutlog.append({'fid': fid, 'side': Sg, 'gidx': g, 'pass': pg, 'd': round(float(np.hypot(APPX[r] - spx, APPY[r] - spy)), 1), 'px': round(float(APPX[r]), 1), 'py': round(float(AP_PY[r]), 1)})

--- итоговые frames: ядро + арбитраж ---

finaltracks = [] for S in sorted(fwdmap): frall = {} for fid, fr in core[S].items(): frall[fid] = {**fr, 'src': 'core', 'pass': 'both'} for fid, fr in mergedframes[S].items(): frall[fid] = fr fidssorted = sorted(frall.keys()) framesout = [] for fid in fidssorted: fr = frall[fid] r = APROW[int(fr['gidx'])] e = EALL[r] dgk = (float(np.linalg.norm(e - sideobjs[S][1])) if np.isfinite(e).all() else None) framesout.append({'fid': int(fid), 'gidx': int(fr['gidx']), 'bbox': fr['bbox'], 'px': fr['px'], 'py': fr['py'], 'vx': fr.get('vx'), 'vy': fr.get('vy'), 'dgk': (round(dgk, 3) if dgk is not None else None), 'src': fr['src'], 'pass': fr.get('pass')}) # команда по ядру (+arb) голосам FTA votes = Counter() for fr in framesout: t = int(APTEAM[APROW[int(fr['gidx'])]]) if t in (0, 1): votes[t] += 1 tot = votes[0] + votes[1] protocore = sideobjs[S][1] d0 = float(np.linalg.norm(protocore - CENTS[0])) d1 = float(np.linalg.norm(protocore - CENTS[1])) if tot >= TEAMMINVOTES and max(votes.values()) / tot >= TEAMHYST: team = 0 if votes[0] > votes[1] else 1 teamsoft = False else: team = 0 if d0 <= d1 else 1 teamsoft = True # статистика px = np.array([fr['px'] for fr in framesout]); py = np.array([fr['py'] for fr in framesout]) fidst = np.array([fr['fid'] for fr in framesout]) pa = ((px >= PAX[S][0]) & (px <= PAX[S][1]) & (py >= PAY[0]) & (py <= PAY[1])) third = (px <= 35.0) if S == 'L' else (px >= 70.0) gasolo = 0 for fr in framesout: gso = GKC.get(fr['fid'], {}).get('gasologidx', {}).get(S) if gso is not None and int(gso) == int(fr['gidx']): gasolo += 1 dg = [fr['dgk'] for fr in framesout if fr['dgk'] is not None] f0t, f1t = int(fidst.min()), int(fidst.max()) finaltracks.append({ 'side': S, 'team': int(team), 'teamsoft': bool(teamsoft), 'mode': fwdbyside[S]['mode'], 'fidstart': f0t, 'fidend': f1t, 'nframes': len(framesout), 'selfid': fwdbyside[S].get('selfid'), 'selcrit': fwdbyside[S].get('selcrit'), 'selfidbwd': (bwdtracks[S].selfid if S in bwdtracks else None), 'ncore': len(core[S]), 'narb': int(sum(1 for fr in framesout if fr['src'] == 'arb')), 'protoe': protocore.tolist(), 'vlproto': (sideobjs[S][2].tolist() if sideobjs[S][2] is not None else None), 'stats': {'pashare': round(float(pa.mean()), 3) if len(pa) else 0.0, 'gasoloshare': round(gasolo / max(1, len(framesout)), 3), 'thirdshare': round(float(third.mean()), 3) if len(third) else 0.0, 'nlost': int((f1t - f0t + 1) - len(framesout)), 'puritycore': (round(float(np.mean(np.array(dg) <= TMATCH)), 3) if dg else None)}, 'frames': framesout})

================== 6. Инварианты ==================

assert len(finaltracks) <= 2, "❌ I1: > 2 треков" assert len({t['side'] for t in finaltracks}) == len(finaltracks), "❌ I1: стороны дублируются" for t in finaltracks: assert all(int(APCLS[APROW[int(fr['gidx'])]]) != CLSREF for fr in t['frames']), \ "❌ I3: ref в треке" fidst = [fr['fid'] for fr in t['frames']] assert len(fidst) == len(set(fidst)), "❌ I4: два назначения в одном кадре" gall = [int(fr['gidx']) for t in finaltracks for fr in t['frames']] assert len(gall) == len(set(gall)), "❌ I4: детекция в двух треках" print(f"✅ Инварианты I1–I4 после слияния: OK")

================== 7. Диагностика ==================

print(f"\n🔄 Обратная селекция (окна исчезновения): {len(sellogbwd)} закрытых окон") rejb = Counter(e.get('reject', 'created') for e in sellogbwd) print(f" Исходы: {dict(rejb)}") for e in sellogbwd: if e.get('created'): print(f" ✅ ПОДТВЕРЖДЁН GK{e['side']}: окно f{e['lo']}–{e['hi']} nw={e['nw']} | " f"критерии: {'+'.join(e['crit'])} | medd={e['medd']} nout={e['nout']} | " f"Кр4: n={e['k4n']} med={e['med4']} | Кр2 n={e['n2']} Кр3 str={e['streak3']} | " f"goal={e['goald']}м | режим {e['mode']}")

print(f"\n🔀 Слияние:") for S in sorted(fwdmap): nf, nb = len(fwdmap[S]), len(bwdmap.get(S, {})) ncore = len(core[S]) conf = (set(fwdmap[S]) | set(bwdmap.get(S, {}))) - set(core[S]) narb = sum(1 for t in finaltracks if t['side'] == S for fr in t['frames'] if fr['src'] == 'arb') ncut = sum(1 for c in cutlog if c['side'] == S) print(f" GK{S}: fwd {nf} кадров | bwd {nb} | ядро {ncore} " f"({100*ncore/max(1,nf):.0f}% fwd) | конфликтных {len(conf)} | " f"арбитраж принял {narb} | вырезано {ncut}") if cutlog: for S in sorted(fwdbyside): cl = [c for c in cutlog if c['side'] == S] if not cl: continue xs = [c['px'] for c in cl] fs = [c['fid'] for c in cl] passes = Counter(c['pass'] for c in cl) print(f" GK_{S}: вырезано {len(cl)} (проходы: {dict(passes)}), " f"кадры f{min(fs)}..{max(fs)}, медианный x={np.median(xs):.1f} м " f"(пул полевых)")

print(f"\n📋 Итоговые треки (после слияния):") for t in finaltracks: cov = t['nframes'] / max(1, t['fidend'] - t['fidstart'] + 1) print(f" GK{t['side']}: team {t['team']}{' (soft)' if t['teamsoft'] else ''} | " f"режим {t['mode']} | f{t['fidstart']}..{t['fidend']} | кадров {t['nframes']} " f"(core {t['ncore']}, arb {t['narb']}) | coverage {100*cov:.0f}% | " f"third {t['stats']['thirdshare']} pa {t['stats']['pashare']} " f"gasolo {t['stats']['gasoloshare']} | purity {t['stats']['puritycore']} | " f"дыры {t['stats']['nlost']}") fd = fwdbyside[t['side']] print(f" было (fwd): third={fd['stats'].get('thirdshare')}, " f"кадров={fd['nframes']}, f..{fd['fid_end']}")

--- приёмка ТЗ 13.3 ---

tl = {t['side']: t for t in finaltracks} okRend = ('R' in tl and tl['R']['fidend'] >= FIDS[-1] - 10) nconftotal = len(conflictfids) nunion = sum(len(set(fwdmap[S]) | set(bwdmap.get(S, {}))) for S in fwdmap) print(f"\n📋 Приёмка ТЗ 13.3: GKR продлён до ~f{FIDS[-1]}: " f"{'✅' if okRend else '⚠️'} | конфликтных кадров {nconftotal} " f"({100*nconftotal/max(1,nunion):.0f}% от union назначений; у GKL — " f"выявлено и вырезано ложное продолжение fwd — ожидаемо и исправлено)")

================== 8. Сохранение + глобали ==================

GKTRACKS = finaltracks GKEXCLUDEDGIDX = set(int(fr['gidx']) for t in finaltracks for fr in t['frames']) GKOWNERMAP = {int(fr['gidx']): f"GK{t['side']}" for t in finaltracks for fr in t['frames']} payload = {'meta': {'cell': CELLTAG, 'src': SRC, 'fwdsrc': FWDSRC, 'fps': FPS, 'params': {'ARBPOSGATEM': ARBPOSGATEM, 'ARBHARDM': ARBHARDM, 'ARBHARDCOL': ARBHARDCOL, 'CMAXARB': CMAXARB, 'WARBPOS': WARBPOS, 'WARBCOL': WARBCOL, 'WARBVL': WARBVL, 'SMOOTHMEDWIN': SMOOTHMEDWIN, 'EXTRAPCAPM': EXTRAPCAPM, 'TMATCH': TMATCH}, 'ntracks': len(finaltracks), 'nconflictframes': nconftotal, 'ncut': len(cutlog)}, 'tracks': finaltracks, 'bwdsellog': sellogbwd, 'arblog': arblog, 'cutlog': cutlog} with open(MRGPATH, 'w', encoding='utf-8') as f: json.dump(payload, f, ensureascii=False, separators=(',', ':')) print(f"\n💾 {MRGPATH}") print(f"✅ {CELLTAG} готов ({time.perfcounter() - t00:.1f} c). Глобали обновлены: " "GKTRACKS (merged), GKEXCLUDEDGIDX, GKOWNER_MAP. " "Следующий шаг — 31G (RTS-сглаживание, refinement, верификация, экспорт, визуализация).")

@title 31G. Этап 4 GK-блока: RTS-сглаживание, refinement, верификация, экспорт, визуализация

#

Вход: gktracksmerged.json (30G; при рестарте — файл, иначе память) + данные 28G v3.

[8.1] RTS-сглаживание (двунаправленный Kalman, CV) -> sx, sy, vx, vy на КАЖДОМ

кадре [fidstart..fidend]; прогноз через дыры = интерполяция (8.3);

[8.2] прямая валидация детекций: позиция <= POSGATEM от сглаженной траектории И

цвет <= адаптивного гейта к финальному прототипу. ПОПРАВКА к букве ТЗ (11):

адаптивный гейт gate = clip(median + KCOLADAPT1.4826MAD(dgk), T_MATCH,

COLGATECAP) — буквальный TMATCH-гейт резал бы ~54% кадров GKL из-за

внутритрекового разброса цвета режима A (ядро двухпроходное, позиция чистая —

это не вкрапления). Оба варианта purity печатаются. Нарушители -> вырезаются,

возвращаются в пул полевых;

[8.3] дыры: поиск незакреплённых детекций (conf не фильтруется = ослабленный порог)

в окне HOLESEARCHM от сглаженной траектории, цвет <= gate -> src='recovered';

остальные дыры -> src='gap' с позициями из RTS;

[8.4] fallback: незакреплённые детекции В ЗОНАХ GA/PA стороны S (буквально ТЗ) с

цветом <= min(gate, EXTDGKMAX); режим A — дополнительно DISTTEAM > TMATCH;

[8.5] инварианты I1–I4;

[8.6] верификация: pashare / gasoloshare / thirdshare; команда по голосам FTA

финального трека (с печатью); TL != TR; ID-switch (цветовые плато);

популяционный контроль false-GK (командные счётчики после исключения GK);

[10] экспорт gktracksfinal.json + глобали GKTRACKS / GKEXCLUDED_GIDX /

GKOWNERMAP; визуализация: макет с зонами GA/PA (пунктир = не видна) и

треками обеих команд + 6 контрольных кадров видео с боксами GK.

Видео читается ТОЛЬКО для визуализации (6 кадров); остальное — кэши/numpy.

import os, json, time import numpy as np import cv2 import matplotlib.pyplot as plt from collections import Counter

t00 = time.perfcounter() CELLTAG = '31G'

================== КОНСТАНТЫ ТЗ ==================

TOUT = float(globals().get('TOUT', 2.5)) TMATCH = float(globals().get('TMATCH', 1.2)) VMAXGK = float(globals().get('VMAXGK', 10.0)) CENTERX = float(globals().get('CENTERX', 52.5)) GAY = tuple(globals().get('GAY', (24.84, 43.16))) PAY = tuple(globals().get('PAY', (13.84, 54.16))) GAX = globals().get('GAX') or {'L': (-1.0, 5.5), 'R': (99.5, 106.0)} PAX = globals().get('PAX') or {'L': (-1.0, 16.5), 'R': (88.5, 106.0)} KFQ, KFR = 6.0, 0.20 TEAMHYST, TEAMMIN_VOTES = 0.6, 3

--- локальные параметры 31G (Этап 4) ---

POSGATEM = 6.0 # 8.2: гейт позиции от сглаженной траектории (м) KCOLADAPT = 2.5 # 8.2: адаптивный цветовой гейт = median + K1.4826MAD COLGATECAP = 3.0 # потолок цветового гейта (пол — TMATCH) HOLESEARCHM = 4.0 # 8.3: радиус поиска пропущенных детекций у траектории EXTDGKMAX = 2.5 # 8.4: жёсткий цветовой гейт fallback-доцепления IDSEGFRAC, IDDMIN, IDRATIO = 0.30, 1.0, 1.5 # ID-switch: два устойчивых плато POPMINTEAM = 7 # популяционный контроль: мин. игроков команды в кадре

CLSGK = int(globals().get('CLSGK', 1)) CLSREF = int(globals().get('CLSREF', 3)) CACHEDIR = str(globals().get('CACHEDIR', '/content/cache')) OUTPUTDIR = str(globals().get('OUTPUTDIR', '/content/output')) MRGPATH = os.path.join(OUTPUTDIR, 'gktracksmerged.json') FINPATH = os.path.join(OUTPUTDIR, 'gktracksfinal.json') VISDIR = os.path.join(OUTPUTDIR, 'debugframes', 'cell31') os.makedirs(VISDIR, existok=True) TEAMBGR = {0: (0, 140, 255), 1: (255, 80, 80)}

================== 1. Данные (память 28G | кэши) ==================

need = ['APGIDX', 'APFID', 'APCLS', 'APX1', 'APY1', 'APX2', 'APY2', 'APPX', 'APPY', 'APPROJ', 'APTEAM', 'APCOLOR12', 'APDISTDIRECT'] if all(v in globals() and globals()[v] is not None for v in need): APGIDX = globals()['APGIDX']; APFID = globals()['APFID']; APCLS = globals()['APCLS'] APX1 = globals()['APX1']; APY1 = globals()['APY1'] APX2 = globals()['APX2']; APY2 = globals()['APY2'] APPX = globals()['APPX']; APPY = globals()['APPY']; APPROJ = globals()['APPROJ'] APTEAM = globals()['APTEAM']; APCOLOR12 = globals()['APCOLOR12'] dd = np.asarray(globals()['APDISTDIRECT'], np.float32) VLGIDX = globals().get('VLGIDX'); VLEMB = globals().get('VLEMB') SRC = 'memory28G' else: ap = os.path.join(CACHEDIR, 'appearancecache.npz') assert os.path.exists(ap), f"❌ {ap} — выполните 28 v4 + 28G v3." with np.load(ap) as z: APGIDX = z['gidx'].astype(np.int64); APFID = z['fid'].astype(np.int32) APCLS = z['cls'].astype(np.int8) APX1 = z['x1'].astype(np.float32); APY1 = z['y1'].astype(np.float32) APX2 = z['x2'].astype(np.float32); APY2 = z['y2'].astype(np.float32) APPX = z['pitchx'].astype(np.float32); APPY = z['pitchy'].astype(np.float32) APPROJ = z['proj'].astype(bool); APTEAM = z['team'].astype(np.int8) APCOLOR12 = z['color12'].astype(np.float32) assert 'distdirect' in z.files, "❌ Нет distdirect — выполните 28G v3." dd = z['distdirect'].astype(np.float32) VLGIDX = (z['vlgidx'].astype(np.int64) if 'vlgidx' in z.files else np.zeros(0, np.int64)) VLEMB = (z['vlemb'].astype(np.float32) if 'vlemb' in z.files else np.zeros((0, 0), np.float32)) SRC = 'cache' NAP = len(APGIDX) if VLGIDX is None: VLGIDX = np.zeros(0, np.int64); VLEMB = np.zeros((0, 0), np.float32) VLPOS = {int(g): i for i, g in enumerate(VLGIDX.tolist())} APROW = {int(g): i for i, g in enumerate(APGIDX.tolist())}

if isinstance(globals().get('APFRAMEROWS'), dict) and globals()['APFRAMEROWS']: APFRAMEROWS = globals()['APFRAMEROWS'] else: o = np.argsort(APFID, kind='stable'); f = APFID[o] u, s = np.unique(f, returnindex=True); e = np.append(s[1:], len(f)) APFRAMEROWS = {int(u): np.sort(o[s:e]) for u, s, e in zip(u, s, e)} FIDS = sorted(APFRAMEROWS.keys()) FPS = float(globals().get('VIDEO_FPS', 25.0))

if isinstance(globals().get('GKCFRAMES'), dict) and globals()['GKCFRAMES']: GKC = globals()['GKCFRAMES'] else: c = os.path.join(CACHEDIR, 'gkcontextframes.json') assert os.path.exists(c), f"❌ {c} — выполните 28G." with open(c, encoding='utf-8') as f: GKC = {int(k): v for k, v in json.load(f).get('frames', {}).items()} GKP = {} if isinstance(globals().get('GKPFRAMES'), dict) and globals()['GKPFRAMES']: GKP = globals()['GKPFRAMES'] else: p = os.path.join(OUTPUTDIR, 'gkframeprimitives.json') if os.path.exists(p): with open(_p, encoding='utf-8') as f: GKP = {int(k): v for k, v in json.load(f).get('frames', {}).items()}

PROTOJ = globals().get('PROTO') if not isinstance(PROTOJ, dict): pp = os.path.join(OUTPUTDIR, 'teamprototypes.json') assert os.path.exists(pp), "❌ teamprototypes.json — выполните ячейку 22." with open(pp, encoding='utf-8') as f: PROTOJ = json.load(f) BLOCKS = PROTOJ.get('blocks', []) CENTS = np.asarray(PROTOJ['centroids_scaled'], np.float32)

def embed(c12): if not BLOCKS: return np.asarray(c12, np.float32) vs = [] for b in BLOCKS: s0, s1 = int(b['slice'][0]), int(b['slice'][1]) keep = np.asarray(b['keepdims'], int) part = c12[:, s0:s1][:, keep] vs.append(((part - np.asarray(b['mean'], np.float32)) / np.maximum(np.asarray(b['scale'], np.float32), 1e-6) ).astype(np.float32) / np.sqrt(max(1, len(keep)))) return np.hstack(vs) if len(vs) > 1 else vs[0] EALL = embed(APCOLOR12) assert EALL.shape[1] == CENTS.shape[1], "❌ D(color) != D(centroids)."

--- merged-треки 30G: файл (авторитет) | память ---

if os.path.exists(MRGPATH): with open(MRGPATH, encoding='utf-8') as f: MTRACKS = json.load(f)['tracks']; MRGSRC = 'json' elif isinstance(globals().get('GKTRACKS'), list) and globals()['GKTRACKS']: MTRACKS = globals()['GKTRACKS']; MRGSRC = 'memory30G' else: raise RuntimeError(f"❌ Нет {MRGPATH} и нет GKTRACKS в памяти — выполните 30G.") assert 1 <= len(MTRACKS) <= 2, "❌ Ожидался 1–2 merged-трека." print(f"⚙️ {CELLTAG}: данные {SRC} | merged {MRG_SRC} | треков {len(MTRACKS)} | кадров {len(FIDS)}")

def pickuniform(lst, k): if k <= 0 or not lst: return [] if k >= len(lst): return list(lst) return [lst[i] for i in np.linspace(0, len(lst) - 1, k).astype(int)]

================== 2. RTS-сглаживатель (CV, дыры = прогноз) ==================

def rtssmooth(detbyfid, flo, fhi): fids = list(range(flo, fhi + 1)) n = len(fids) if n == 1: px, py, = detbyfid[flo] return {flo: (px, py, 0.0, 0.0)} dt = 1.0 / FPS F = np.array([[1, 0, dt, 0], [0, 1, 0, dt], [0, 0, 1, 0], [0, 0, 0, 1]], np.float64) Q = KFQ * np.array([[dt**4/4, 0, dt**3/2, 0], [0, dt**4/4, 0, dt**3/2], [dt**3/2, 0, dt**2, 0], [0, dt**3/2, 0, dt**2]], np.float64) H = np.array([[1., 0, 0, 0], [0, 1., 0, 0]], np.float64) R = np.eye(2) * KFR I4 = np.eye(4) x = np.array([detbyfid[fids[0]][0], detbyfid[fids[0]][1], 0.0, 0.0], np.float64) P = np.diag([0.5, 0.5, 9.0, 9.0]) xf, Pf, xp, Pp = [None]n, [None]n, [None]n, [None]n for i, f in enumerate(fids): if i > 0: x = F @ x; P = F @ P @ F.T + Q xp[i] = x.copy(); Pp[i] = P.copy() if f in detbyfid: z = np.array(detbyfid[f][:2], np.float64) S = H @ P @ H.T + R K = P @ H.T @ np.linalg.inv(S) x = x + K @ (z - H @ x) IKH = I4 - K @ H P = IKH @ P @ IKH.T + K @ R @ K.T xf[i] = x.copy(); Pf[i] = P.copy() xs = [None]n xs[n-1] = xf[n-1].copy() for i in range(n-2, -1, -1): C = Pf[i] @ F.T @ np.linalg.inv(Pp[i+1]) xs[i] = xf[i] + C @ (xs[i+1] - xp[i+1]) out = {} for i, f in enumerate(fids): s = xs[i] v = np.array([s[2], s[3]], np.float64) vn = float(np.hypot(v)) if vn > VMAXGK: v = v * (VMAXGK / vn) out[f] = (float(s[0]), float(s[1]), float(v[0]), float(v[1])) return out

def inzone(px, py, side): ga = (GAX[side][0] <= px <= GAX[side][1]) and (GAY[0] <= py <= GAY[1]) pa = (PAX[side][0] <= px <= PAX[side][1]) and (PAY[0] <= py <= PAY[1]) return ga or pa

================== 3. Refinement по трекам ==================

usedgidx = set() for mt in MTRACKS: for fr in mt['frames']: if fr.get('gidx') is not None: usedgidx.add(int(fr['gidx']))

FIN, CUTALL, RECALL, EXT_ALL = [], [], [], []

for mt in MTRACKS: side, mode = mt['side'], mt['mode'] detbyfid = {} for fr in mt['frames']: g = fr.get('gidx') if g is None: continue r = APROW[int(g)] detbyfid[int(fr['fid'])] = (float(APPX[r]), float(APPY[r]), int(r)) assert len(detbyfid) >= 3, f"❌ GK{side}: <3 детекций после слияния" proto = np.asarray(mt['protoe'], np.float32) cutlog, reclog, extlog = [], [], []

# --- адаптивный цветовой гейт (8.2, поправка к 11) --- dgk0 = [float(np.linalg.norm(EALL[r] - proto)) for (, , r) in detbyfid.values() if np.isfinite(EALL[r]).all()] med = float(np.median(dgk0)) mad = float(np.median(np.abs(np.asarray(dgk0) - med))) 1.4826 gate = float(np.clip(med + K_COL_ADAPT mad, TMATCH, COLGATE_CAP))

# --- 8.2: валидация (<=2 итераций: RTS -> нарушители -> вырезание) --- for it in range(2): flo, fhi = min(detbyfid), max(detbyfid) sm = rtssmooth(detbyfid, flo, fhi) viol = [] for fid, (px, py, r) in detbyfid.items(): sx, sy = sm[fid][0], sm[fid][1] pe = float(np.hypot(px - sx, py - sy)) e = EALL[r] dg = (float(np.linalg.norm(e - proto)) if np.isfinite(e).all() else None) badpos = pe > POSGATEM badcol = (dg is None) or (dg > gate) if badpos or badcol: viol.append((fid, r, pe, dg, badpos, badcol)) if not viol: break for fid, r, pe, dg, bp, bc in viol: cutlog.append({'fid': int(fid), 'gidx': int(APGIDX[r]), 'reason': ('pos+color' if (bp and bc) else 'pos' if bp else 'color'), 'poserr': round(pe, 2), 'dgk': (round(dg, 3) if dg is not None else None)}) usedgidx.discard(int(APGIDX[r])) del detbyfid[fid] assert len(detbyfid) >= 3, f"❌ GK_{side}: валидация вырезала почти весь трек"

# --- финальный прототип по выжившим --- rowslive = [r for (, , r) in detbyfid.values()] El = EALL[rowslive] protof = El[np.isfinite(El).all(1)].mean(axis=0).astype(np.float32)

# --- 8.3: поиск пропущенных детекций в дырах (у сглаженной траектории) --- flo, fhi = min(detbyfid), max(detbyfid) sm = rtssmooth(detbyfid, flo, fhi) for fid in range(flo, fhi + 1): if fid in detbyfid: continue sx, sy = sm[fid][0], sm[fid][1] best = None for r in APFRAMEROWS.get(fid, []): if int(APCLS[r]) == CLSREF or not APPROJ[r]: continue g = int(APGIDX[r]) if g in usedgidx: continue d = float(np.hypot(APPX[r] - sx, APPY[r] - sy)) if d > HOLESEARCHM: continue e = EALL[r] if not np.isfinite(e).all(): continue dg = float(np.linalg.norm(e - protof)) if dg > gate: continue if best is None or (d + dg) < best[0]: best = (d + dg, r, d, dg) if best is not None: , r, d, dg = best detbyfid[fid] = (float(APPX[r]), float(APPY[r]), int(r)) usedgidx.add(int(APGIDX[r])) reclog.append({'fid': int(fid), 'gidx': int(AP_GIDX[r]), 'd': round(d, 2), 'dgk': round(dg, 3)})

# --- 8.4: fallback — незакреплённые в зонах GA/PA(S), цвет <= min(gate, EXTDGKMAX) --- fhi = max(detbyfid) if fhi < FIDS[-1] - 3: for fid in range(fhi + 1, FIDS[-1] + 1): for r in APFRAMEROWS.get(fid, []): if int(APCLS[r]) == CLSREF or not APPROJ[r]: continue g = int(APGIDX[r]) if g in usedgidx: continue px, py = float(APPX[r]), float(APPY[r]) if not inzone(px, py, side): continue e = EALL[r] if not np.isfinite(e).all(): continue dg = float(np.linalg.norm(e - protof)) if dg > min(gate, EXTDGKMAX): continue if mode == 'A' and not (np.isfinite(dd[r]) and dd[r] > TMATCH): continue detbyfid[fid] = (px, py, int(r)) usedgidx.add(g) extlog.append({'fid': int(fid), 'gidx': g, 'dgk': round(dg, 3), 'px': round(px, 1), 'py': round(py_, 1)})

# --- 8.1 (финал): RTS по итоговому набору -> кадры диапазона --- flo, fhi = min(detbyfid), max(detbyfid) sm = rtssmooth(detbyfid, flo, fhi) recfids = {e['fid'] for e in reclog} extfids = {e['fid'] for e in extlog} framesout = [] for fid in range(flo, fhi + 1): sx, sy, vx, vy = sm[fid] rec = {'fid': int(fid), 'gidx': None, 'bbox': None, 'px': None, 'py': None, 'sx': round(sx, 2), 'sy': round(sy, 2), 'vx': round(vx, 2), 'vy': round(vy, 2), 'src': 'gap', 'dgk': None} if fid in detbyfid: px, py, r = detbyfid[fid] e = EALL[r] dg = (float(np.linalg.norm(e - protof)) if np.isfinite(e).all() else None) rec.update({'gidx': int(APGIDX[r]), 'bbox': [round(float(APX1[r]), 1), round(float(APY1[r]), 1), round(float(APX2[r]), 1), round(float(APY2[r]), 1)], 'px': round(px, 2), 'py': round(py, 2), 'src': ('recovered' if fid in recfids else 'ext' if fid in extfids else 'det'), 'dgk': (round(dg, 3) if dg is not None else None)}) framesout.append(rec)

# --- статистика (8.6) --- detsf = [f for f in framesout if f['gidx'] is not None] sxa = np.array([f['sx'] for f in framesout]) sya = np.array([f['sy'] for f in framesout]) pam = ((sxa >= PAX[side][0]) & (sxa <= PAX[side][1]) & (sya >= PAY[0]) & (sya <= PAY[1])) thirdm = (sxa <= 35.0) if side == 'L' else (sxa >= 70.0) gasolo = sum(1 for f in detsf if GKC.get(f['fid'], {}).get('gasologidx', {}).get(side) == f['gidx']) poserrs, dgks = [], [] for f in detsf: poserrs.append(float(np.hypot(f['px'] - f['sx'], f['py'] - f['sy']))) if f['dgk'] is not None: dgks.append(f['dgk']) poserrs = np.array(poserrs); dgks = np.array(dgks) puritytm = float(np.mean(dgks <= TMATCH)) if len(dgks) else None purityad = (float(np.mean((dgks <= gate) & (poserrs[:len(dgks)] <= POSGATE_M))) if len(dgks) else None)

# --- команда по голосам FTA финального трека (с печатью) --- vc = Counter(int(APTEAM[APROW[f['gidx']]]) for f in detsf) tot = vc.get(0, 0) + vc.get(1, 0) d0 = float(np.linalg.norm(protof - CENTS[0])) d1 = float(np.linalg.norm(protof - CENTS[1])) if tot >= TEAMMINVOTES and max(vc.get(0, 0), vc.get(1, 0)) / tot >= TEAMHYST: team = 0 if vc.get(0, 0) > vc.get(1, 0) else 1 teamsoft = False else: team = 0 if d0 <= d1 else 1 teamsoft = True

# --- ID-switch: два устойчивых цветовых плато --- idswitch, dab, intra = False, None, None Es = [EALL[APROW[f['gidx']]] for f in detsf] Es = [e for e in Es if np.isfinite(e).all()] if len(Es) >= 10: k = max(3, int(IDSEGFRAC * len(Es))) A, B = np.stack(Es[:k]), np.stack(Es[-k:]) mA, mB = A.mean(0), B.mean(0) dab = float(np.linalg.norm(mA - mB)) intra = float(max(np.median(np.linalg.norm(A - mA, axis=1)), np.median(np.linalg.norm(B - mB, axis=1)))) idswitch = bool(dab > IDDMIN and dab > IDRATIO * intra)

vlrows = [VLPOS[f['gidx']] for f in detsf if f['gidx'] in VLPOS] vlproto = None if vlrows: vlproto = VLEMB[vlrows].mean(axis=0) vlproto = (vlproto / max(1e-6, float(np.linalg.norm(vlproto)))).tolist()

FIN.append({'side': side, 'team': int(team), 'teamsoft': bool(teamsoft), 'mode': mode, 'fidstart': int(flo), 'fidend': int(fhi), 'nframes': len(framesout), 'frames': framesout, 'protoe': protof.tolist(), 'vlproto': vlproto, 'stats': {'pashare': round(float(pam.mean()), 3), 'gasoloshare': round(gasolo / max(1, len(detsf)), 3), 'thirdshare': round(float(thirdm.mean()), 3), 'nlost': int(len(framesout) - len(detsf)), 'nrecovered': len(reclog), 'ncut': len(cutlog), 'next': len(extlog), 'ndets': len(detsf), 'coverage': round(len(framesout) / (fhi - flo + 1), 3), 'puritytmatch': (round(puritytm, 3) if puritytm is not None else None), 'purityadaptive': (round(purityad, 3) if purityad is not None else None), 'colgate': round(gate, 3), 'idswitch': idswitch, 'iddab': dab, 'idintra': intra}, 'votes': {'t0': int(vc.get(0, 0)), 't1': int(vc.get(1, 0)), 'X': int(vc.get(2, 0)), 'nolabel': int(vc.get(-1, 0))}, 'team30g': {'team': int(mt['team']), 'soft': bool(mt['teamsoft'])}}) CUTALL += [dict(c, side=side) for c in cutlog] RECALL += [dict(c, side=side) for c in reclog] EXTALL += [dict(c, side=side) for c in ext_log]

================== 4. Инварианты I1–I4 + верификация ==================

assert len(FIN) <= 2 and len({t['side'] for t in FIN}) == len(FIN), "❌ I1" for t in FIN: fidst = [f['fid'] for f in t['frames'] if f['gidx'] is not None] assert len(fidst) == len(set(fidst)), "❌ I4: два назначения в кадре" assert all(int(APCLS[APROW[f['gidx']]]) != CLSREF for f in t['frames'] if f['gidx'] is not None), "❌ I3" gall = [f['gidx'] for t in FIN for f in t['frames'] if f['gidx'] is not None] assert len(gall) == len(set(g_all)), "❌ I4: детекция в двух треках" print(f"✅ Инварианты I1–I4: OK")

GKEXCLUDEDGIDX = set(int(g) for g in gall) GKOWNERMAP = {int(f['gidx']): f"GK{t['side']}" for t in FIN for f in t['frames'] if f['gidx'] is not None}

--- популяционный контроль (false-GK) ---

popmin = {0: 10**9, 1: 10**9} popbad = [] nchecked = 0 for fid in FIDS: rows = APFRAMEROWS[fid] lab = [r for r in rows if int(APCLS[r]) != CLSREF and int(APTEAM[r]) in (0, 1)] if len(lab) < 5: continue nchecked += 1 for tm in (0, 1): after = sum(1 for r in lab if int(APTEAM[r]) == tm and int(APGIDX[r]) not in GKEXCLUDEDGIDX) popmin[tm] = min(popmin[tm], after) gkx = sum(1 for r in lab if int(APTEAM[r]) == tm and int(APGIDX[r]) in GKEXCLUDEDGIDX) if gkx > 1: popbad.append({'fid': int(fid), 'team': tm, 'gkexcluded': int(gkx)}) popok = (popmin[0] >= POPMINTEAM and popmin[1] >= POPMINTEAM and not pop_bad)

tl = {t['side']: t for t in FIN} tlneq = (len(FIN) == 2 and tl['L']['team'] != tl['R']['team'])

================== 5. Диагностика + метрики приёмки (ТЗ 11) ==================

print(f"\n🔧 Refinement:") for t in FIN: st = t['stats'] print(f" GK{t['side']}: цветовой гейт {st['colgate']} (TMATCH={TMATCH}) | " f"вырезано {st['ncut']} | доцеплено (дыры) {st['nrecovered']} | " f"fallback (зоны) {st['next']}") if CUTALL: for c in CUTALL[:12]: print(f" ✂ GK{c['side']} f{c['fid']} gidx={c['gidx']} ({c['reason']}: " f"pos={c['poserr']}м, dgk={c['dgk']}) -> пул полевых") if RECALL: for c in RECALL[:12]: print(f" 🔗 GK{c['side']} f{c['fid']} gidx={c['gidx']} " f"(d={c['d']}м, dgk={c['dgk']})") if EXTALL: for c in EXTALL[:12]: print(f" ➕ GK_{c['side']} f{c['fid']} gidx={c['gidx']} " f"({c['px']},{c['py']}) dgk={c['dgk']}")

print(f"\n📋 Финальные треки (ТЗ 12):") for t in FIN: st = t['stats']; v = t['votes'] print(f" GK{t['side']}: команда {t['team']}{' (soft, raw-цвет)' if t['teamsoft'] else ''} " f"| 30G: team {t['team30g']['team']} soft={t['team30g']['soft']} | " f"голоса FTA: t0:{v['t0']} t1:{v['t1']} X:{v['X']} без метки:{v['nolabel']}") print(f" режим {t['mode']} | f{t['fidstart']}..{t['fidend']} | " f"кадров {t['nframes']} (детекций {st['ndets']}, дыр {st['nlost']}) | " f"coverage {st['coverage']} | pa {st['pashare']} gasolo {st['gasoloshare']} " f"third {st['thirdshare']}") print(f" purity: TMATCH={st['puritytmatch']} | адаптивный∧позиция=" f"{st['purityadaptive']} | ID-switch: {st['idswitch']} " f"(dseg={st['iddab']}, intra={st['id_intra']})")

print(f"\n📋 Метрики приёмки (ТЗ 11):") for t in FIN: st = t['stats'] print(f" GK{t['side']}: coverage {st['coverage']} " f"{'✅' if st['coverage'] >= 0.9 else '⚠️'} | " f"purity(адаптивный) {st['purityadaptive']} " f"{'✅' if (st['purityadaptive'] or 0) >= 0.98 else '⚠️'} | " f"purity(TMATCH, буквальный) {st['puritytmatch']} " f"{'✅' if (st['puritytmatch'] or 0) >= 0.98 else '⚠️ (внутритрековый разброс цвета, не вкрапления)'} | " f"ID-switch {'0 ✅' if not st['idswitch'] else 'ОБНАРУЖЕН ⚠️'}") print(f" false-GK: популяционный контроль: min/кадр t0={popmin[0]}, t1={popmin[1]} " f"(порог {POPMINTEAM}) {'✅' if popok else '⚠️ ' + str(popbad[:5])} | " f"TL≠TR: {'✅' if tlneq else '⚠️'} ({', '.join(f'GK{t[chr(39)+chr(39)] if False else t['side']}: t{t['team']}' for t in FIN)})")

================== 6. Экспорт (ТЗ 10) ==================

GKTRACKS = FIN payload = {'meta': {'cell': CELLTAG, 'src': SRC, 'mergedsrc': MRGSRC, 'fps': FPS, 'params': {'TMATCH': TMATCH, 'POSGATEM': POSGATEM, 'KCOLADAPT': KCOLADAPT, 'COLGATECAP': COLGATECAP, 'HOLESEARCHM': HOLESEARCHM, 'EXTDGKMAX': EXTDGKMAX, 'KFQ': KFQ, 'KFR': KFR, 'POPMINTEAM': POPMINTEAM}, 'puritynote': ('purityadaptive: gate=clip(median+2.51.4826MAD, ' 'TMATCH, 3.0) ∧ позиция<=POSGATEM; ' 'puritytmatch — буквальная операционализация ТЗ 11'), 'ntracks': len(FIN), 'ncut': len(CUTALL), 'nrecovered': len(RECALL), 'next': len(EXTALL), 'population': {'mint0': int(popmin[0]), 'mint1': int(popmin[1]), 'ok': bool(popok)}, 'tlneqtr': bool(tlneq)}, 'tracks': FIN, 'cutlog': CUTALL, 'recoveredlog': RECALL, 'extlog': EXTALL} with open(FINPATH, 'w', encoding='utf-8') as f: json.dump(payload, f, ensureascii=False, separators=(',', ':')) print(f"\n💾 {FINPATH}") print(f" Глобали: GKTRACKS ({len(FIN)} трека), GKEXCLUDEDGIDX " f"({len(GKEXCLUDEDGIDX)} gidx), GKOWNERMAP ({len(GKOWNERMAP)})")

================== 7. Визуализация: макет + контрольные кадры ==================

SMM, MMM = 8, 5 MW, MH = int((105 + 2 M_MM) SMM), int((68 + 2 * MMM) S_MM) def _to_px(x, y): return int((x + M_MM) SMM), int((y + MMM) S_MM) canvas = np.zeros((MH, MW, 3), np.uint8); canvas[:] = (12, 62, 12) if 'PITCH_CONFIG' in globals() and 'PITCH_VERTICES_M' in globals(): for (a, b) in PITCH_CONFIG.edges: cv2.line(canvas, _to_px(PITCHVERTICESM[a - 1]), topx(PITCH_VERTICES_M[b - 1]), (230, 230, 230), 2, cv2.LINE_AA) else: cv2.rectangle(canvas, _to_px(0, 0), _to_px(105, 68), (230, 230, 230), 2, cv2.LINE_AA) cv2.line(canvas, _to_px(52.5, 0), _to_px(52.5, 68), (230, 230, 230), 2, cv2.LINE_AA) cv2.circle(canvas, _to_px(52.5, 34.0), int(round(9.15 SMM)), (230, 230, 230), 2, cv2.LINEAA)

def dashline(img, p1, p2, col, thick, dash=7, gap=6): p1 = np.array(p1, float); p2 = np.array(p2, float) d = p2 - p1; L = float(np.hypot(d)) if L < 1: return u = d / L; t = 0.0 while t < L: t2 = min(t + dash, L) cv2.line(img, tuple((p1 + u t).astype(int)), tuple((p1 + u * t2).astype(int)), col, thick, cv2.LINE_AA) t = t2 + gap

def rectmm(img, xs, ys, col, thick, dashed): p1, p2 = topx(xs[0], ys[0]), topx(xs[1], ys[1]) if not dashed: cv2.rectangle(img, p1, p2, col, thick, cv2.LINEAA) else: for a, b in ((p1, (p2[0], p1[1])), ((p2[0], p1[1]), p2), (p2, (p1[0], p2[1])), ((p1[0], p2[1]), p1)): dash_line(img, a, b, col, thick)

def zonevis(zone, side): if not GKP: return 0.0 return float(np.mean([bool(GKP.get(f, {}).get(zone, {}).get(side, False)) for f in FIDS]))

for side in ('L', 'R'): rectmm(canvas, GAX[side], GAY, (230, 230, 230), 2, zonevis('visga', side) < 0.5) rectmm(canvas, PAX[side], PAY, (200, 200, 200), 1, zonevis('vispa', side) < 0.5)

for t in FIN: col = TEAMBGR[t['team']] pts = np.array([topx(f['sx'], f['sy']) for f in t['frames']], np.int32) cv2.polylines(canvas, [pts], False, col, 3, cv2.LINEAA) for f in t['frames']: if f['gidx'] is not None: cv2.circle(canvas, topx(f['sx'], f['sy']), 3, col, -1, cv2.LINEAA) cv2.circle(canvas, topx(t['frames'][0]['sx'], t['frames'][0]['sy']), 7, (255, 255, 255), 2, cv2.LINEAA) ex, ey = topx(t['frames'][-1]['sx'], t['frames'][-1]['sy']) cv2.putText(canvas, f"GK{t['side']} (t{t['team']})", (ex + 8, ey - 8), cv2.FONTHERSHEYSIMPLEX, 0.8, (255, 255, 255), 3, cv2.LINEAA) cv2.putText(canvas, f"GK{t['side']} (t{t['team']})", (ex + 8, ey - 8), cv2.FONTHERSHEYSIMPLEX, 0.8, col, 1, cv2.LINEAA)

fig, ax = plt.subplots(figsize=(14, 8)) ax.imshow(cv2.cvtColor(canvas, cv2.COLORBGR2RGB)) ax.settitle("31G: треки вратарей (RTS) | пунктирная зона = не видна камерой | " "точки = детекции, линия = сглаженная траектория") ax.axis('off') plt.tightlayout() plt.savefig(os.path.join(VISDIR, 'gktracksminimap.png'), dpi=130, bboxinches='tight') plt.show() print(f"🖼️ Макет: {VISDIR}/gktracksminimap.png")

--- 6 контрольных кадров видео (3 на трек) с боксами GK ---

if callable(globals().get('itervideoframes')): targets = [] for t in FIN: detfids = [f['fid'] for f in t['frames'] if f['gidx'] is not None] targets += pickuniform(detfids, 3) targets = sorted(set(targets))[:6] needed = set(targets) framesbyid = {} for fid, frame in itervideoframes(): if fid in needed: framesbyid[int(fid)] = frame needed.discard(int(fid)) if not needed: break if framesbyid: fidsshow = [f for f in targets if f in framesbyid] ncols = len(fidsshow) fig, axes = plt.subplots(2, ncols, figsize=(5.2 ncols, 8.5)) axes = np.array(axes).reshape(2, ncols) if ncols > 1 else np.array([[axes[0]], [axes[1]]]) for ci, fid in enumerate(fids_show): vis = frames_by_id[fid].copy() for r in AP_FRAME_ROWS.get(fid, []): # контекст: все персон x1, y1 = int(AP_X1[r]), int(AP_Y1[r]) x2, y2 = int(AP_X2[r]), int(AP_Y2[r]) cv2.rectangle(vis, (x1, y1), (x2, y2), (110, 110, 110), 1) for t in FIN: fr = next((q for q in t['frames'] if q['fid'] == fid and q['gidx'] is not None), None) if fr is None: continue col = TEAM_BGR[t['team']] x1, y1, x2, y2 = [int(v) for v in fr['bbox']] cv2.rectangle(vis, (x1, y1), (x2, y2), col, 3) cv2.circle(vis, (int(fr['px'] 0 + x1 + (x2 - x1) // 2), y2), 4, col, -1) cv2.putText(vis, f"GK{t['side']} t{t['team']}", (x1, max(14, y1 - 8)), cv2.FONTHERSHEYSIMPLEX, 0.7, (0, 0, 0), 3, cv2.LINEAA) cv2.putText(vis, f"GK{t['side']} t{t['team']}", (x1, max(14, y1 - 8)), cv2.FONTHERSHEYSIMPLEX, 0.7, col, 1, cv2.LINEAA) cv2.rectangle(vis, (0, 0), (vis.shape[1], 34), (0, 0, 0), -1) cv2.putText(vis, f"frame {fid}", (10, 24), cv2.FONTHERSHEYSIMPLEX, 0.8, (255, 255, 255), 2, cv2.LINEAA) outp = os.path.join(VISDIR, f"ctrl{fid:06d}.jpg") cv2.imwrite(outp, vis, [int(cv2.IMWRITEJPEGQUALITY), 88]) axes[0, ci].imshow(cv2.cvtColor(vis, cv2.COLORBGR2RGB)) axes[0, ci].settitle(f"f{fid}", fontsize=11); axes[0, ci].axis('off') # мини-врезка: позиция на макете mini = canvas.copy() for t in FIN: fr = next((q for q in t['frames'] if q['fid'] == fid and q['gidx'] is not None), None) if fr is not None: c = topx(fr['sx'], fr['sy']) cv2.circle(mini, c, 10, (0, 0, 255), 3, cv2.LINEAA) axes[1, ci].imshow(cv2.cvtColor(mini, cv2.COLORBGR2RGB)) axes[1, ci].settitle("минимапа", fontsize=9); axes[1, ci].axis('off') plt.suptitle("31G: контрольные кадры (GK-боксы) + позиции на макете", fontsize=13) plt.tightlayout() plt.show() print(f"🖼️ Контрольные кадры: {VISDIR}") else: print("⚠️ itervideoframes недоступен — контрольные кадры пропущены")

if callable(globals().get('freememory')): freememory()

print() print(f"✅ {CELLTAG} готов ({time.perfcounter() - t00:.1f} c). " "GK-блок завершён: 28G v3 -> 29G -> 30G -> 31G.") print(" Далее: трекинг полевых игроков (ячейка 29) использует GKEXCLUDEDGIDX " "(исключить GK-детекции из пула) и GKOWNERMAP (possession: GKL/GKR).")

@title 28F. Этап 0 трекера полевых: пул, FTA-консистентность, диагностика П1, K_t

#

Первый этап F-блока (28F -> 29F -> 30F -> 31F -> 32F). Выполняется ПОСЛЕ GK-блока.

[1] FTA-консистентность: team/dist из памяти/кэша (версия, с которой работал

GK-блок) сверяются с frameteamassignment.json + team_prototypes.json на

диске (последний запуск ячейки 22). Контроль перестановки команд t0<->t1

между версиями. Источник истины — ДИСК: APTEAM/APDIST перечитываются,

EALL/APDIST_DIRECT пересчитываются по прототипам диска. Команды GK

(T(L)/T(R)) пересчитываются в шкале диска по proto_e GK-треков (цвет GK-детекций

в новой шкале) с позиционным fallback.

[2] Пул полевых (Опр. 1): классы player/gk, минус ref, минус GKEXCLUDEDGIDX,

минус cut-детекции GK из gktracksfinal.json (В5). Проверка разбиения

пула/GK/cut/ref = все persons. Диагностика gk-класс остатков (доля в зонах GK).

[3] Диагностика П1 (контрфактическая, до реализации — В1): на пуле строятся

взаимно-ближайшие геометрические рёбра соседних кадров (плоскость, без

цветовых/командных гейтов). Классификация рёбер: командный конфликт 0<->1

(с разделением: «мерцание» — цвет рёбра близок; «возможный своп» — цвет далёк),

через серого, размерный гейт, G5b, цветовой дальняк. Осцилляции меток

0<->1 (сегмент <=10 кадров) вдоль геометрических цепочек. Симуляция

фрагментации: A1 (рвём на командных конфликтах), A2 (+ серые UNASSIGNED),

base (чистая геометрия) — медианы длительностей и бины. Авто-вердикт П1.

[4] K_t (В6): медианные per-frame счёты команд по пулу (f>калибровки) + серые

пропорционально; выравнивание K1+K2 = round(медиана размера пула).

Без чтения видео; детерминировано; секунды.

import os, json, time import numpy as np from collections import Counter, defaultdict

================== КОНСТАНТЫ ТЗ (разд. 2; единые для 28F–32F) ==================

SELWINDOW = 6 # кадров в окне селекции игрока SELPERIODCAP = 75 # кадров: потолок селекционного периода (10% клипа) KSELWINDOW = 6 # кадров для начальной оценки скорости игрока TMATCHCOL = 1.5 # мягкий порог цветового соответствия (E-пространство) TVLMATCH = 0.30 # мягкий порог VL (1-cos)/2 VMAXFIELD = 10.0 # м/с: потолок эффективной скорости поиска VMAXKF = 9.0 # м/с: физический потолок скорости в Kalman RBASE = 2.5 # м: базовый радиус ассоциации KEXPAND = 1.15 # рост эффективной скорости поиска за кадр потери RCAP = 40.0 # м: потолок радиуса при потере CMAX = 1.2 # порог cost Венгра WPOS, WDIR, WCOL = 0.5, 0.2, 0.3 # веса cost (позиция/направление/цвет) SIZELOGMAX = 0.7 # гейт отношения высот bbox (|log h/hmed|) WGROUPBASE = 1.4 # G5b: множитель ширины при hmed >= 60 px WGROUPSMALL = 1.7 # G5b: множитель ширины при hmed < 60 px WGROUPHTHR = 60.0 # G5b: порог высоты (px) GAPDRAW = 8 # кадров: разрыв линии при визуализации SELSTREAK_TEAM = 3 # минимум согласованных командных меток окна якоря (В3)

--- локальные параметры 28F (диагностика П1, оценка K_t) ---

P1LINKM = 2.5 # радиус геометрической связи соседних кадров (м) P1LINKGAP = 2 # пропуск кадров в жадной сшивке цепочек P1NEARM = 6.0 # радиус «есть ли кто-то рядом» при классификации разрывов FRAGMINFRAMES = 10 # минимум длительности фрагмента для медиан симуляции OSCILMAXLEN = 10 # кадров: максимум сегмента для осцилляции 0<->1 CHAINMINFLICK = 50 # кадров: минимум длины цепочки для анализа мерцания

CLSBALL, CLSGK, CLSPLAYER, CLSREF = 0, 1, 2, 3 for v, d in (('CLSBALL', 0), ('CLSGK', 1), ('CLSPLAYER', 2), ('CLSREF', 3)): if v in globals() and globals()[v] is not None: globals()[v] = int(globals()[v]) CLSBALL, CLSGK, CLSPLAYER, CLSREF = (int(globals().get('CLSBALL', CLSBALL)), int(globals().get('CLSGK', CLSGK)), int(globals().get('CLSPLAYER', CLSPLAYER)), int(globals().get('CLSREF', CLSREF)))

t00 = time.perfcounter() CELLTAG = '28F' CACHEDIR = str(globals().get('CACHEDIR', '/content/cache')) OUTPUTDIR = str(globals().get('OUTPUTDIR', '/content/output')) FTAPATH = os.path.join(OUTPUTDIR, 'frameteamassignment.json') PROTOPATH = os.path.join(OUTPUTDIR, 'teamprototypes.json') GKFINPATH = os.path.join(OUTPUTDIR, 'gktracksfinal.json') REPORTPATH = os.path.join(OUTPUTDIR, 'fieldstage0report.json') os.makedirs(CACHEDIR, existok=True); os.makedirs(OUTPUTDIR, exist_ok=True)

=====================================================

1. Данные: AP_* (память | кэш), FTA/прототипы (диск), GK final

=====================================================

APCACHEPATH = os.path.join(CACHEDIR, 'appearancecache.npz') need = ['APGIDX', 'APFID', 'APCLS', 'APCONF', 'APX1', 'APY1', 'APX2', 'APY2', 'APH', 'APW', 'APPX', 'APPY', 'APPROJ', 'APTEAM', 'APDIST', 'APCOLOR12', 'APDISTDIRECT'] APSRC = 'memory' if not all(v in globals() and globals()[v] is not None for v in need): assert os.path.exists(APCACHEPATH), \ f"❌ Нет AP* в памяти и нет {APCACHEPATH} — выполните 28 v4 + 28G v3." with np.load(APCACHEPATH) as z: APGIDX = z['gidx'].astype(np.int64) APFID = z['fid'].astype(np.int32) APCLS = z['cls'].astype(np.int8) APCONF = z['conf'].astype(np.float32) APX1 = z['x1'].astype(np.float32); APY1 = z['y1'].astype(np.float32) APX2 = z['x2'].astype(np.float32); APY2 = z['y2'].astype(np.float32) APH = z['h'].astype(np.float32); APW = z['w'].astype(np.float32) APPX = z['pitchx'].astype(np.float32); APPY = z['pitchy'].astype(np.float32) APPROJ = z['proj'].astype(bool) APTEAM = z['team'].astype(np.int8) APDIST = z['dist'].astype(np.float32) APCOLOR12 = z['color12'].astype(np.float32) for k in ('distdirect',): assert k in z.files, f"❌ В кэше нет {k} — выполните 28G v3." APDISTDIRECT = z['distdirect'].astype(np.float32) VLGIDX = (z['vlgidx'].astype(np.int64) if 'vlgidx' in z.files else np.zeros(0, np.int64)) VLEMB = (z['vlemb'].astype(np.float32) if 'vlemb' in z.files else np.zeros((0, 0), np.float32)) APSRC = 'cache' else: APGIDX = globals()['APGIDX']; APFID = globals()['APFID'] APCLS = globals()['APCLS']; APCONF = globals()['APCONF'] APX1 = globals()['APX1']; APY1 = globals()['APY1'] APX2 = globals()['APX2']; APY2 = globals()['APY2'] APH = globals()['APH']; APW = globals()['APW'] APPX = globals()['APPX']; APPY = globals()['APPY'] APPROJ = globals()['APPROJ'] APTEAM = globals()['APTEAM']; APDIST = globals()['APDIST'] APCOLOR12 = globals()['APCOLOR12'] APDISTDIRECT = np.asarray(globals()['APDISTDIRECT'], np.float32) VLGIDX = globals().get('VLGIDX'); VLEMB = globals().get('VLEMB') NAP = len(APGIDX) if VLGIDX is None: VLGIDX = np.zeros(0, np.int64); VLEMB = np.zeros((0, 0), np.float32) VLPOS = {int(g): i for i, g in enumerate(VLGIDX.tolist())} APROW = {int(g): i for i, g in enumerate(APGIDX.tolist())}

if isinstance(globals().get('APFRAMEROWS'), dict) and globals()['APFRAMEROWS'] \ and APSRC == 'memory': APFRAMEROWS = globals()['APFRAMEROWS'] else: o = np.argsort(APFID, kind='stable'); f = APFID[o] u, s = np.unique(f, returnindex=True); e = np.append(s[1:], len(f)) APFRAMEROWS = {int(u): np.sort(o[s:e]) for u, s, e in zip(u, s, e)} FIDS = sorted(APFRAMEROWS.keys()) FPS = float(globals().get('VIDEOFPS', 25.0))

--- FTA + прототипы с диска (источник истины) ---

assert os.path.exists(FTAPATH), f"❌ {FTAPATH} — выполните ячейку 22." assert os.path.exists(PROTOPATH), f"❌ {PROTOPATH} — выполните ячейку 22." with open(FTAPATH, 'r', encoding='utf-8') as f: fta = json.load(f) FTAFRAMES = {int(k): v for k, v in fta.get('frames', {}).items()} FTAMETA = fta.get('meta', {}) teamdisk, distdisk = {}, {} for recs in FTAFRAMES.values(): for r in recs: teamdisk[int(r['gidx'])] = int(r['team']) if r.get('dist') is not None: distdisk[int(r['gidx'])] = float(r['dist']) with open(PROTOPATH, 'r', encoding='utf-8') as f: PROTO = json.load(f) BLOCKS = PROTO.get('blocks', []) CENTS = np.asarray(PROTO['centroidsscaled'], np.float32) assert CENTS.shape[0] == 2, "❌ centroidsscaled: ожидаются 2 центроида" GRAYTHRESH = float(PROTO.get('graythresh', 3.5)) tn = {int(k): v for k, v in FTAMETA.get('teamnames', {}).items()} un = [k for k, v in tn.items() if v == 'unassigned'] UNASSIGNED = int(un[0]) if un else 2 LASTCALIBFID = None lcf = FTAMETA.get('autotune', {}).get('lastcalibfid') if lcf is not None: LASTCALIBFID = int(lcf)

inter = len(set(teamdisk) & set(APROW)) assert inter >= 0.9 * len(teamdisk), \ f"❌ FTA на диске согласуется с кэшем детекций на {inter}/{len(team_disk)} gidx — другой сегмент?"

=====================================================

2. FTA-консистентность память/кэш <-> диск

=====================================================

def embedblocks(c12): if not BLOCKS: return np.asarray(c12, np.float32).copy() vecs = [] for b in BLOCKS: s0, s1 = int(b['slice'][0]), int(b['slice'][1]) keep = np.asarray(b['keep_dims'], int) mean = np.asarray(b['mean'], np.float32) scale = np.maximum(np.asarray(b['scale'], np.float32), 1e-6) part = c12[:, s0:s1][:, keep] vecs.append(((part - mean) / scale).astype(np.float32) / np.sqrt(max(1, len(keep)))) return np.hstack(vecs) if len(vecs) > 1 else vecs[0]

print("🔍 Проверка FTA-консистентности (память/кэш ↔ диск):") memteam = {} for i, g in enumerate(APGIDX.tolist()): t = int(APTEAM[i]) if t != -1: memteam[int(g)] = t common = sorted(set(memteam) & set(teamdisk)) c01 = [g for g in common if memteam[g] in (0, 1) and teamdisk[g] in (0, 1)] agreeall = (float(np.mean([memteam[g] == teamdisk[g] for g in common])) if common else float('nan')) agree01 = (float(np.mean([memteam[g] == teamdisk[g] for g in c01])) if c01 else float('nan')) swap01 = (float(np.mean([memteam[g] != teamdisk[g] for g in c01])) if c01 else float('nan')) ftaverdict = 'consistent' if common and agreeall >= 0.95: print(f" ✅ Согласовано: {len(common)} общих gidx, agree={100*agreeall:.2f}%") elif common and swap01 >= 0.95: ftaverdict = 'swapped' print(f" ⚠️ ОБНАРУЖЕНА ПЕРЕСТАНОВКА КОМАНД: agree={100*agreeall:.2f}%, " f"после swap t0<->t1: {100swap01:.2f}% ({len(c01)} меток 0/1)") print(f" Память/кэш и диск — от РАЗНЫХ запусков ячейки 22. " f"Источником истины назначается ДИСК (последний запуск).") elif common: fta_verdict = 'partial' print(f" ⚠️ Частичный рассинхрон: {len(common)} общих, agree={100agree_all:.2f}%, " f"swap={100*swap01:.2f}% — используем ДИСК") else: print(" ℹ️ Нет общих меток — используем ДИСК")

--- перечёт в шкале диска (всегда: единая шкала для 28F–32F) ---

EALL = embedblocks(APCOLOR12) assert EALL.shape[1] == CENTS.shape[1], "❌ D(color) != D(centroids) — прототипы другой версии 22." d0 = np.linalg.norm(EALL - CENTS[0][None, :], axis=1) d1 = np.linalg.norm(EALL - CENTS[1][None, :], axis=1) dd = np.full(NAP, np.nan, np.float32) okdd = np.isfinite(EALL).all(1) & (APCLS != CLSREF) dd[okdd] = np.minimum(d0[okdd], d1[okdd]) APDISTDIRECT = dd APTEAM = np.full(NAP, -1, np.int8) APDIST = np.full(NAP, np.nan, np.float32) for i, g in enumerate(APGIDX.tolist()): t = teamdisk.get(int(g)) if t is not None: APTEAM[i] = t d = distdisk.get(int(g)) if d is not None: APDIST[i] = d

=====================================================

3. GK: восстановление глобалей + команды GK в шкале диска (T(L)/T(R))

=====================================================

assert os.path.exists(GKFINPATH), f"❌ {GKFINPATH} не найден — выполните GK-блок (31G)." with open(GKFINPATH, 'r', encoding='utf-8') as f: GKFIN = json.load(f) GKTRACKS = GKFIN['tracks'] GKEXCLUDEDGIDX = set() GKOWNERMAP = {} gkrowsbyside = {} for t in GKTRACKS: rows = [] for fr in t['frames']: g = fr.get('gidx') if g is None: continue g = int(g) if g in APROW: rows.append(APROW[g]) GKEXCLUDEDGIDX.add(g) GKOWNERMAP[g] = f"GK{t['side']}" gkrowsbyside[t['side']] = np.asarray(rows, np.int64) GKCUTGIDX = set(int(c['gidx']) for c in GKFIN.get('cut_log', []))

print(f"\n🧤 GK-блок: треков {len(GKTRACKS)} | закреплено {len(GKEXCLUDEDGIDX)} gidx | " f"cut-детекций (возврат в пул у 31G): {len(GKCUTGIDX)} — исключаются из пула (В5)") SIDETEAM = {} for t in GKTRACKS: rows = gkrowsbyside[t['side']] Ev = EALL[rows] Ev = Ev[np.isfinite(Ev).all(1)] assert len(Ev) >= 3, f"❌ GK{t['side']}: <3 детекций с цветом" protonew = Ev.mean(axis=0) d0 = float(np.linalg.norm(protonew - CENTS[0])) d1 = float(np.linalg.norm(protonew - CENTS[1])) tnew = 0 if d0 <= d1 else 1 told = int(t['team']) SIDETEAM[t['side']] = tnew mark = '' if tnew == told else ' ← ОТЛИЧАЕТСЯ от версии 31G (шкала FTA сменилась)' print(f" GK{t['side']}: команда в шкале диска = {tnew} " f"(d0={d0:.2f}, d1={d1:.2f}); в 31G была {told}{mark}") if len(SIDETEAM) == 2 and SIDETEAM['L'] == SIDETEAM['R']: print(" ⚠️ T(L)==T(R) по цвету — позиционный fallback: левее = T(L)") medx = {} for tm in (0, 1): m = (APTEAM == tm) medx[tm] = float(np.nanmedian(APPX[m])) if m.any() else 1e9 SIDETEAM['L'] = 0 if medx[0] <= medx[1] else 1 SIDETEAM['R'] = 1 - SIDETEAM['L'] print(f" медианные x команд: t0={medx[0]:.1f}, t1={medx[1]:.1f} -> " f"T(L)={SIDETEAM['L']}, T(R)={SIDETEAM['R']}") TL, TR = SIDETEAM.get('L'), SIDETEAM.get('R') SIDEOFTEAM = {v: k for k, v in SIDETEAM.items()} print(f" Соответствие: команда 1 (=T(L), GKL) = FTA-team {TL} | " f"команда 2 (=T(R), GKR) = FTA-team {T_R}")

=====================================================

4. Пул полевых (Опр. 1 + В5)

=====================================================

isgkexcl = np.isin(APGIDX, np.asarray(sorted(GKEXCLUDEDGIDX), np.int64)) \ if GKEXCLUDEDGIDX else np.zeros(NAP, bool) iscut = np.isin(APGIDX, np.asarray(sorted(GKCUTGIDX), np.int64)) \ if GKCUTGIDX else np.zeros(NAP, bool) isref = APCLS == CLSREF FIELDPOOL = np.isin(APCLS, [CLSPLAYER, CLSGK]) & (~isgkexcl) & (~iscut) FIELDFRAMEROWS = {int(f): APFRAMEROWS[int(f)][FIELDPOOL[APFRAMEROWS[int(f)]]] for f in FIDS} FIELDN = int(FIELD_POOL.sum())

проверка разбиения (каждая person-детекция ровно в одной категории)

partitionok = (int(FIELDPOOL.sum()) + int(isgkexcl.sum()) + int(is_cut.sum())

  • int(isref.sum()) == NAP) print(f"\n🧍 Пул полевых: {FIELDN} детекций ({FIELDN/len(FIDS):.2f}/кадр) | " f"player-класс: {int((FIELDPOOL & (APCLS == CLSPLAYER)).sum())} | " f"gk-класс остатки: {int((FIELDPOOL & (APCLS == CLSGK)).sum())}") print(f" Разбиение пул/GK/cut/ref покрывает всех persons: " f"{'✅' if partitionok else '⚠️ НЕТ'} " f"({FIELDN}+{int(isgkexcl.sum())}+{int(iscut.sum())}+{int(isref.sum())} из {NAP})") pf = np.array([len(FIELDFRAMEROWS[f]) for f in FIDS]) print(f" Размер пула на кадр: mean={pf.mean():.2f} min={int(pf.min())} " f"max={int(pf.max())} медиана={float(np.median(pf)):.1f}")

--- gk-класс остатки: где они (в зонах GK?) ---

mgk = FIELDPOOL & (APCLS == CLSGK) & np.isfinite(APPX) if mgk.any(): ingkzone = (((APPX[mgk] >= -1.0) & (APPX[mgk] <= 16.5) & (APPY[mgk] >= 13.84) & (APPY[mgk] <= 54.16)) | ((APPX[mgk] >= 88.5) & (APPX[mgk] <= 106.0) & (APPY[mgk] >= 13.84) & (APPY[mgk] <= 54.16))) print(f" gk-класс остатки: {int(mgk.sum())} дет., " f"{100*float(ingkzone.mean()):.0f}% в штрафных зонах GK " f"{'⚠️ (возможны пропуски GK-треков — следить в 29F)' if ingkzone.mean() > 0.3 else ''}")

=====================================================

5. Диагностика П1 (контрфактическая, В1)

=====================================================

print(f"\n🔬 Диагностика П1 (геометрия без цветовых/командных гейтов; " f"взаимно-ближайшие пары соседних кадров, d<{P1LINKM} м):")

--- рёбра: взаимно-ближайшие по плоскости ---

EDGEA, EDGEB, EDGED = [], [], [] for i in range(len(FIDS) - 1): fa, fb = FIDS[i], FIDS[i + 1] A = [int(r) for r in FIELDFRAMEROWS[fa] if APPROJ[r] and np.isfinite(APPX[r]) and np.isfinite(APPY[r])] B = [int(r) for r in FIELDFRAMEROWS[fb] if APPROJ[r] and np.isfinite(APPX[r]) and np.isfinite(APPY[r])] if not A or not B: continue bmap, amap = {}, {} for a in A: d = np.hypot(APPX[B] - APPX[a], APPY[B] - APPY[a]) j = int(np.argmin(d)) if d[j] < P1LINKM: bmap[a] = (B[j], float(d[j])) for b in B: d = np.hypot(APPX[A] - APPX[b], APPY[A] - APPY[b]) j = int(np.argmin(d)) if d[j] < P1LINKM: amap[b] = (A[j], float(d[j])) for a, (b, d) in bmap.items(): if amap.get(b, (None,))[0] == a: EDGEA.append(a); EDGEB.append(b); EDGED.append(d) EDGEA = np.asarray(EDGEA, np.int64); EDGEB = np.asarray(EDGEB, np.int64) EDGED = np.asarray(EDGED, np.float32) nedges = len(EDGEA)

--- классификация рёбер ---

ta, tb = APTEAM[EDGEA], APTEAM[EDGEB] conflict = np.isin(ta, (0, 1)) & np.isin(tb, (0, 1)) & (ta != tb) grayedge = (~np.isin(ta, (0, 1)) | ~np.isin(tb, (0, 1))) & (~conflict) okedge = (~conflict) & (~grayedge) dE = np.linalg.norm(EALL[EDGEA] - EALL[EDGEB], axis=1) finitee = np.isfinite(EALL[EDGEA]).all(1) & np.isfinite(EALL[EDGEB]).all(1) sizeviol = np.abs(np.log(np.maximum(APH[EDGEB], 1.0) / np.maximum(APH[EDGEA], 1.0))) > SIZELOGMAX wg = np.where(APH[EDGEA] >= WGROUPHTHR, WGROUPBASE, WGROUPSMALL) g5bviol = APW[EDGEB] > wg * APW[EDGEA] colorfar = finitee & (dE > TMATCHCOL) flicker = conflict & finitee & (dE <= TMATCHCOL) # конфликт, но цвет тот же -> мерцание maybeswap = conflict & (~finitee | (dE > TMATCHCOL))

--- разрывы геометрии (нет преемника) ---

nlost, njump = 0, 0 for i in range(len(FIDS) - 1): fa, fb = FIDS[i], FIDS[i + 1] A = [int(r) for r in FIELDFRAMEROWS[fa] if APPROJ[r] and np.isfinite(APPX[r]) and np.isfinite(APPY[r])] B = [int(r) for r in FIELDFRAMEROWS[fb] if APPROJ[r] and np.isfinite(APPX[r]) and np.isfinite(APPY[r])] if not A: continue if not B: nlost += len(A) continue for a in A: d = np.hypot(APPX[B] - APPX[a], APPY[B] - APPY[a]) dmin = float(d.min()) if dmin < P1LINKM: continue if dmin < P1NEARM: njump += 1 # есть рядом, но дальше радиуса (лечится расширением R) else: n_lost += 1 # никого рядом (потеря детекции / выход)

print(f" Рёбер геометрии: {nedges} | разрывов: {nlost} (нет детекции рядом) + " f"{njump} (скачок {P1LINKM}–{P1NEARM} м)") if nedges: print(f" Рёбра с командным конфликтом 0↔1: {int(conflict.sum())} " f"({100float(conflict.mean()):.1f}%) — предшественник с жёстким гейтом рвал бы ЗДЕСЬ") print(f" из них: мерцание (цвет рёбра ≤{T_MATCH_COL}): {int(flicker.sum())} | " f"возможный реальный своп (цвет далёк): {int(maybe_swap.sum())}") print(f" d_E на конфликтных рёбрах: p50={np.percentile(dE[conflict], 50):.2f} " f"p90={np.percentile(dE[conflict], 90):.2f}" if int(conflict.sum()) else "") print(f" Рёбра через серого: {int(gray_edge.sum())} ({100float(grayedge.mean()):.1f}%) | " f"размерный гейт: {int(sizeviol.sum())} | G5b: {int(g5bviol.sum())} | " f"цветовой дальняк (> {TMATCHCOL}): {int(colorfar.sum())}")

--- цепочки (жадная сшивка) + мерцание вдоль ---

def buildchains(): chains, live = [], [] for fid in FIDS: live = [ch for ch in live if ch['lastfid'] >= fid - P1LINKGAP] rows = [int(r) for r in FIELDFRAMEROWS[fid] if APPROJ[r] and np.isfinite(APPX[r]) and np.isfinite(APPY[r])] for r in sorted(rows, key=lambda q: -APCONF[q]): best, bd = None, P1LINKM for ch in live: if fid < ch['lastfid']: continue d = float(np.hypot(APPX[r] - ch['px'], APPY[r] - ch['py'])) if d < bd: bd, best = d, ch if best is not None: best['rows'].append(r); best['lastfid'] = fid best['px'] = float(APPX[r]); best['py'] = float(APPY[r]) else: ch = {'rows': [r], 'lastfid': fid, 'px': float(APPX[r]), 'py': float(AP_PY[r])} chains.append(ch); live.append(ch) return chains

CHAINS = buildchains() longchains = [ch for ch in CHAINS if len(ch['rows']) >= CHAINMINFLICK] nswitchtotal, nosciltotal = 0, 0 flickerchains = [] for ch in longchains: rows = sorted(ch['rows'], key=lambda r: APFID[r]) marks = [(int(APFID[r]), int(APTEAM[r])) for r in rows if APTEAM[r] in (0, 1)] if len(marks) < 5: continue switches = sum(1 for k in range(len(marks) - 1) if marks[k][1] != marks[k + 1][1]) nswitchtotal += switches segs = [] for f, t in marks: if segs and segs[-1][0] == t: segs[-1][2] = f else: segs.append([t, f, f]) osc = sum(1 for k in range(1, len(segs) - 1) if segs[k - 1][0] == segs[k + 1][0] and segs[k][2] - segs[k][1] <= OSCILMAXLEN) nosciltotal += osc if switches >= 1: flickerchains.append((switches, osc, ch['rows'])) flickerchains.sort(key=lambda x: -x[0]) print(f" Цепочек: {len(CHAINS)} (длиной ≥{CHAINMINFLICK} кадров: {len(longchains)})") print(f" Переключений команды 0↔1 вдоль длинных цепочек: {nswitchtotal} | " f"осцилляций (возврат ≤{OSCILMAXLEN} кадров): {nosciltotal}") if flickerchains: print(f" Цепочек с ≥1 переключением: {len(flickerchains)}; топ-3:") for sw, osc, rows in flickerchains[:3]: fidsch = APFID[rows] print(f" переключений {sw}, осцилляций {osc} | f{int(fidsch.min())}.." f"{int(fidsch.max())} | детекций {len(rows)}")

--- симуляция фрагментации A1/A2/base ---

def fragstats(segments): dur = [int(APFID[seg[-1]] - APFID[seg[0]] + 1) for seg in segments if len(seg) >= 2 and APFID[seg[-1]] - APFID[seg[0]] + 1 >= FRAGMIN_FRAMES] if not dur: return {'n': 0, 'med': None, 'bins': {}} dur = np.asarray(dur) return {'n': int(len(dur)), 'med': float(np.median(dur)), 'bins': {'<5': 0, '5-14': 0, '15-49': 0, '50-149': 0, '150-599': 0, '>=600': 0}}

def segbins(segments): dur = [int(APFID[seg[-1]] - APFID[seg[0]] + 1) for seg in segments] dur = [d for d in dur if d >= FRAGMIN_FRAMES] b = Counter() for d in dur: if d < 5: b['<5'] += 1 elif d < 15: b['5-14'] += 1 elif d < 50: b['15-49'] += 1 elif d < 150: b['50-149'] += 1 elif d < 600: b['150-599'] += 1 else: b['>=600'] += 1 return dict(b)

def splitchain(rows, breakmaskfn): segs, cur = [], [rows[0]] for a, b in zip(rows[:-1], rows[1:]): if breakmask_fn(a, b): segs.append(cur); cur = [b] else: cur.append(b) segs.append(cur) return segs

def a1break(a, b): ta, tb = int(APTEAM[a]), int(APTEAM[b]) return ta in (0, 1) and tb in (0, 1) and ta != tb

def a2break(a, b): if a1break(a, b): return True return int(APTEAM[a]) == UNASSIGNED or int(APTEAM[b]) == UNASSIGNED

segsbase, segsa1, segsa2 = [], [], [] for ch in CHAINS: rows = sorted(ch['rows'], key=lambda r: APFID[r]) if len(rows) < 2: continue segsbase.append(rows) segsa1 += splitchain(rows, a1break) segsa2 += splitchain(rows, a2_break)

def med(segs): d = [int(APFID[s[-1]] - APFID[s[0]] + 1) for s in segs if APFID[s[-1]] - APFID[s[0]] + 1 >= FRAGMIN_FRAMES] return (float(np.median(d)) if d else None), len(d)

medbase, nbase = med(segsbase) meda1, na1 = med(segsa1) meda2, na2 = med(segsa2) print(f"\n Симуляция фрагментации (фрагменты ≥{FRAGMINFRAMES} кадров):") print(f" base (чистая геометрия): {nbase} фрагментов, медиана {medbase}") print(f" A1 (жёсткий командный гейт): {na1} фрагментов, медиана {meda1}") print(f" A2 (гейт + блокировка серых): {na2} фрагментов, медиана {meda2}") print(f" Бины A1: {segbins(segsa1)} | Бины base: {segbins(segsbase)}")

--- авто-вердикт П1 ---

gatebreaks = int(conflict.sum()) + int(grayedge.sum()) + int(size_viol.sum()) \

  • int(g5bviol.sum()) sharegate = (100.0 * float(conflict.sum()) / gatebreaks) if gatebreaks else 0.0 hypoconf = bool((nedges and sharegate >= 50.0) or (meda1 is not None and meda1 < 250 and (medbase or 0) > 400)) print(f"\n Вердикт П1: доля командного гейта среди гейт-нарушений рёбер = " f"{sharegate:.0f}% | медианы A1/base = {meda1}/{medbase}") print(f" {'✅ Гипотеза ПОДТВЕРЖДЕНА: командный гейт — доминирующая причина разрывов; ' 'веса прохода 1 — как в ТЗ (без командного гейта, цвет мягкий).' if hypoconf else '⚠️ Гипотеза НЕ подтверждена: доминируют иные причины — ' 'требуется корректировка R_BASE/G5b (обсудить до 29F).'}")

=====================================================

6. Оценка K_t (В6)

=====================================================

labfids = [f for f in FIDS if f in FTAFRAMES] c0m, c1m, cgm = [], [], [] for fid in labfids: tm = APTEAM[FIELDFRAMEROWS[fid]] c0m.append(int((tm == 0).sum())) c1m.append(int((tm == 1).sum())) cgm.append(int((tm == UNASSIGNED).sum())) c0m, c1m, cgm = np.asarray(c0m), np.asarray(c1m), np.asarray(cgm) med0, med1, medg = float(np.median(c0m)), float(np.median(c1m)), float(np.median(cgm)) KTOTAL = int(round(float(np.median(pf)))) if med0 + med1 > 0: k0raw = med0 + medg * med0 / (med0 + med1) k1raw = med1 + medg med1 / (med0 + med1) else: k0_raw, k1_raw = K_TOTAL / 2.0, K_TOTAL / 2.0 _ksum = k0_raw + k1_raw K_F = {0: int(round(K_TOTAL k0raw / ksum)) if ksum > 0 else 0, 1: 0} KF[1] = KTOTAL - KF[0] p10 = {0: float(np.percentile(c0m, 10)), 1: float(np.percentile(c1m, 10))} p90 = {0: float(np.percentile(c0m, 90)), 1: float(np.percentile(c1m, 90))} print(f"\n👥 Оценка населения Kt (по пулу, f>{LASTCALIBFID}, " f"{len(labfids)} кадров с разметкой):") print(f" Медианы на кадр: t0={med0:.1f}, t1={med1:.1f}, серые={medg:.1f} " f"(распределены пропорционально) | размер пула: медиана {KTOTAL}") print(f" Разброс счётов: t0 p10–p90 = {p10[0]:.0f}–{p90[0]:.0f}, " f"t1 p10–p90 = {p10[1]:.0f}–{p90[1]:.0f}") print(f" → K(FTA-team {TL}) = K(команда 1, GKL) = {KF[TL]} | " f"K(FTA-team {TR}) = K(команда 2, GKR) = {KF[TR]} | всего {KTOTAL}") if KTOTAL != int(round(ksum)): print(f" ℹ️ K1+K2 выровнены к медиане пула ({KTOTAL}); " f"сырые оценки давали {ksum:.1f}")

=====================================================

7. Сохранение + глобали

=====================================================

report = { 'meta': {'cell': CELLTAG, 'apsrc': APSRC, 'fps': FPS, 'nframes': len(FIDS), 'lastcalibfid': LASTCALIBFID, 'constants': {'SELWINDOW': SELWINDOW, 'SELPERIODCAP': SELPERIODCAP, 'KSELWINDOW': KSELWINDOW, 'TMATCHCOL': TMATCHCOL, 'TVLMATCH': TVLMATCH, 'VMAXFIELD': VMAXFIELD, 'VMAXKF': VMAXKF, 'RBASE': RBASE, 'KEXPAND': KEXPAND, 'RCAP': RCAP, 'CMAX': CMAX, 'WPOS': WPOS, 'WDIR': WDIR, 'WCOL': WCOL, 'SIZELOGMAX': SIZELOGMAX, 'WGROUPBASE': WGROUPBASE, 'WGROUPSMALL': WGROUPSMALL, 'WGROUPHTHR': WGROUPHTHR, 'GAPDRAW': GAPDRAW, 'SELSTREAKTEAM': SELSTREAKTEAM}}, 'ftaconsistency': {'verdict': ftaverdict, 'ncommon': len(common), 'agree': agreeall, 'swapagree': swap01}, 'sideteam': {'L': TL, 'R': TR}, 'pool': {'n': FIELDN, 'perframemean': float(pf.mean()), 'perframemedian': float(np.median(pf)), 'perframemin': int(pf.min()), 'perframemax': int(pf.max()), 'gkclassresidue': int((FIELDPOOL & (APCLS == CLSGK)).sum()), 'partitionok': bool(partitionok)}, 'p1': {'nedges': int(nedges), 'conflict': int(conflict.sum()), 'conflictpct': (100.0 * float(conflict.mean()) if nedges else None), 'flicker': int(flicker.sum()), 'maybeswap': int(maybeswap.sum()), 'grayedges': int(grayedge.sum()), 'sizeviol': int(sizeviol.sum()), 'g5bviol': int(g5bviol.sum()), 'colorfar': int(colorfar.sum()), 'breaksnodet': int(nlost), 'breaksjump': int(njump), 'nchains': len(CHAINS), 'nlongchains': len(longchains), 'switchestotal': int(nswitchtotal), 'oscillationstotal': int(nosciltotal), 'frag': {'base': {'n': nbase, 'med': medbase, 'bins': segbins(segsbase)}, 'a1': {'n': na1, 'med': meda1, 'bins': segbins(segsa1)}, 'a2': {'n': na2, 'med': meda2, 'bins': segbins(segsa2)}}, 'gatesharepct': sharegate, 'hypothesisconfirmed': bool(hypoconf)}, 'kest': {'medt0': med0, 'medt1': med1, 'medgray': medg, 'krawt0': k0raw, 'krawt1': k1raw, 'ktotal': KTOTAL, 'kbyteam': {str(k): int(v) for k, v in KF.items()}, 'spread': {'t0': [p10[0], p90[0]], 't1': [p10[1], p90[1]]}}} with open(REPORTPATH, 'w', encoding='utf-8') as f: json.dump(report, f, ensureascii=False, indent=2) print(f"\n💾 {REPORT_PATH}")

if callable(globals().get('freememory')): freememory()

print() print(f"✅ Ячейка {CELLTAG} готова ({time.perfcounter() - t00:.1f} c).") print(" Глобали для 29F: FIELDPOOL, FIELDFRAMEROWS, FIELDN; APTEAM/APDIST/APDISTDIRECT") print(" (шкала диска), EALL, CENTS, GRAYTHRESH, UNASSIGNED, LASTCALIBFID;") print(" SIDETEAM/SIDEOFTEAM, KFIELD (по FTA-team), KTOTALFIELD;") print(" GKEXCLUDEDGIDX, GKOWNERMAP, GKCUTGIDX, GKTRACKS; VLPOS/VLGIDX/VLEMB;") print(" константы ТЗ (SEL*, TMATCHCOL, TVLMATCH, VMAX, R_BASE, K_EXPAND, R_CAP,") print(" C_MAX, W_, SIZELOGMAX, WGROUP*, GAPDRAW).") KFIELD = dict(KF) KTOTALFIELD = int(KTOTAL)

@title 29F v2.2. Совместная инициализация + добор + прямой трекинг

(ФИКС: S4-капы не растворяют — балансировка в 31F; K = доменный 10/10)

#

v2.2 = v2.1 + два исправления (причина потери tid=18 в v2.1):

[F1] ПРИЧИНА: tid=18 (54.9,24.8; в v5-двойник id16, t0:641 — реальный игрок)

имел слабый старт (детектор видел его 2 раза за f0..f7, регулярно — с f8),

его команда определилась на f60-93, t0 стало 12 > K_FIELD[0]=11 (СМЕЩЁННАЯ

оценка 28F) -> "растворить слабейшего" -> растворён он, не набравший

детекций. Итог v2.1: 19 треков (t0=11, t1=8), потерян реальный игрок t0.

[F2] S4-КАПЫ НЕ РАСТВОРЯЮТ: только overflow_suspect-пометка. Балансировка

составов — целиком в 31F (доменный приор 10/10 + голосовое переназначение

mixed; растворение в S4 — доказанный источник потерь реальных игроков).

[F3] K для капов/отчёта — ДОМЕННЫЙ 10/10 (K_FIELD из 28F — только справочно,

печать деталей кап-событий).

Унаследовано из v2.1: S1 (рождение на f0, G5b-фильтр), S2 (Венгр + ДОБОР с

назад-цепочкой), S3 (фиксация + R3-отсев при переполнении >20), S4 (механика:

радиусы, G4/G5a/G5b, cost, Венгр C_MAX, потери, команды голосами), I1-I4, экспорт.

import os, gc, json, time, math import numpy as np from collections import Counter from scipy.optimize import linearsumassignment

t00 = time.perfcounter() CELLTAG = '29F v2.2'

=====================================================

0. ОЗУ-монитор + очистка

=====================================================

def ramgb(): try: with open('/proc/meminfo') as f: for line in f: if line.startswith('MemAvailable:'): return int(line.split()[1]) / 1e6 except Exception: return float('nan') return float('nan')

ram0 = ramgb() try: import matplotlib.pyplot as plt plt.close('all') except Exception: pass try: import torch as torch hastorch = True except Exception: hastorch = False

HEAVY = ['model', 'FIELDMODEL', 'SEGMODEL', 'VLMODEL', 'VLPROCESSOR', 'BASELINETRACKS', 'GKPFRAMES', 'GKCFRAMES', 'kpscache', 'viscache', 'framesbyid', 'balltrack', 'playersbyframe'] freed = [] for n in HEAVY: if n in globals() and globals()[n] is not None: del globals()[n] freed.append(n) gc.collect() if hastorch and torch.cuda.isavailable(): torch.cuda.emptycache() print(f"🧹 Очистка: удалены {freed if freed else '—'} | ОЗУ: {ram0:.2f} -> {ramgb():.2f} ГБ")

================== КОНСТАНТЫ ТЗ (из 28F) ==================

TMATCHCOL = float(globals().get('TMATCHCOL', 1.5)) TVLMATCH = float(globals().get('TVLMATCH', 0.30)) VMAXFIELD = float(globals().get('VMAXFIELD', 10.0)) VMAXKF = float(globals().get('VMAXKF', 9.0)) RBASE = float(globals().get('RBASE', 2.5)) KEXPAND = float(globals().get('KEXPAND', 1.15)) RCAP = float(globals().get('RCAP', 40.0)) CMAX = float(globals().get('CMAX', 1.2)) WPOS, WDIR, WCOL = (float(globals().get('WPOS', 0.5)), float(globals().get('WDIR', 0.2)), float(globals().get('WCOL', 0.3))) SIZELOGMAX = float(globals().get('SIZELOGMAX', 0.7)) WGROUPBASE = float(globals().get('WGROUPBASE', 1.4)) WGROUPSMALL = float(globals().get('WGROUPSMALL', 1.7)) WGROUPHTHR = float(globals().get('WGROUPHTHR', 60.0)) SELSTREAKTEAM = int(globals().get('SELSTREAKTEAM', 3))

--- доменный приор (F3) ---

FIELDPLAYERSPER_TEAM = 10

--- совместная инициализация (S1/S2) ---

INITFRAMES = 12 INITGATEM = 3.0 WINITPOS = 0.7 WINITCOL = 0.3 BIRTHMIN_H = 15.0

--- ДОБОР ---

REBIRTHMINDIST = 3.0 REBIRTHCHAINM = 3.0 REBIRTHCHAINGAP = 2

--- основной трекинг ---

DIRMINSPEED = 0.3 NOCOLCOST = 0.75 EMACOLALPHA = 0.30 EMAVLALPHA = 0.30 DAMPLOST = 0.90 EXITDISCOUNT = 0.85 EXITAREAFRAC = 0.30 PITCHMARGINM = 3.0 VOTEDETMIN, VOTEDETMAJ = 5, 0.70 CAPRECHECKEVERY, CAPRECHECKUNTIL = 25, 300 KFQFIELD, KFRFIELD = 6.0, 0.20 MEDWIN = 150 HUNGSLOW_S = 0.5

CLSGK, CLSPLAYER, CLSREF = (int(globals().get('CLSGK', 1)), int(globals().get('CLSPLAYER', 2)), int(globals().get('CLSREF', 3))) CACHEDIR = str(globals().get('CACHEDIR', '/content/cache')) OUTPUTDIR = str(globals().get('OUTPUTDIR', '/content/output')) FWDPATH = os.path.join(OUTPUTDIR, 'fieldtracksfwd.json')

=====================================================

1. Данные: память 28F | кэши с диска

=====================================================

if ('FIELDPOOL' in globals() and globals()['FIELDPOOL'] is not None and 'APGIDX' in globals() and globals()['APGIDX'] is not None): SRC = 'memory28F' APGIDX = globals()['APGIDX']; APFID = globals()['APFID'] APCLS = globals()['APCLS']; APCONF = globals()['APCONF'] APX1 = globals()['APX1']; APY1 = globals()['APY1'] APX2 = globals()['APX2']; APY2 = globals()['APY2'] APH = globals()['APH']; APW = globals()['APW'] APPX = globals()['APPX']; APPY = globals()['APPY']; APPROJ = globals()['APPROJ'] APTEAM = globals()['APTEAM'] EALL = np.asarray(globals()['EALL'], np.float32) VLGIDX = globals().get('VLGIDX'); VLEMB = globals().get('VLEMB') FIELDPOOL = globals()['FIELDPOOL'] FIELDFRAMEROWS = globals()['FIELDFRAMEROWS'] KFIELD = {int(k): int(v) for k, v in globals()['KFIELD'].items()} KTOTALFIELD = int(globals()['KTOTALFIELD']) GKEXCLUDEDGIDX = globals()['GKEXCLUDEDGIDX'] GKCUTGIDX = globals().get('GKCUTGIDX', set()) GKOWNERMAP = globals()['GKOWNERMAP'] LASTCALIBFID = globals().get('LASTCALIBFID') else: SRC = 'cache' ap = os.path.join(CACHEDIR, 'appearancecache.npz') ftap = os.path.join(OUTPUTDIR, 'frameteamassignment.json') protop = os.path.join(OUTPUTDIR, 'teamprototypes.json') gkp = os.path.join(OUTPUTDIR, 'gktracksfinal.json') repp = os.path.join(OUTPUTDIR, 'fieldstage0report.json') for p in (ap, ftap, protop, gkp, repp): assert os.path.exists(p), f"❌ {p} не найден — выполните 28 v4, 28G v3, GK-блок, 28F." with np.load(ap) as z: APGIDX = z['gidx'].astype(np.int64); APFID = z['fid'].astype(np.int32) APCLS = z['cls'].astype(np.int8); APCONF = z['conf'].astype(np.float32) APX1 = z['x1'].astype(np.float32); APY1 = z['y1'].astype(np.float32) APX2 = z['x2'].astype(np.float32); APY2 = z['y2'].astype(np.float32) APH = z['h'].astype(np.float32); APW = z['w'].astype(np.float32) APPX = z['pitchx'].astype(np.float32); APPY = z['pitchy'].astype(np.float32) APPROJ = z['proj'].astype(bool) APCOLOR12 = z['color12'].astype(np.float32) APTEAM = z['team'].astype(np.int8) VLGIDX = (z['vlgidx'].astype(np.int64) if 'vlgidx' in z.files else np.zeros(0, np.int64)) VLEMB = (z['vlemb'].astype(np.float32) if 'vlemb' in z.files else np.zeros((0, 0), np.float32)) NAP = len(APGIDX) APROW = {int(g): i for i, g in enumerate(APGIDX.tolist())} with open(ftap, encoding='utf-8') as f: fta = json.load(f) teamdisk = {} for recs in fta.get('frames', {}).values(): for r in recs: teamdisk[int(r['gidx'])] = int(r['team']) with open(protop, encoding='utf-8') as f: PROTO = json.load(f) BLOCKS = PROTO.get('blocks', []) def embed(c12): if not BLOCKS: return np.asarray(c12, np.float32) vs = [] for b in BLOCKS: s0, s1 = int(b['slice'][0]), int(b['slice'][1]) keep = np.asarray(b['keepdims'], int) part = c12[:, s0:s1][:, keep] vs.append(((part - np.asarray(b['mean'], np.float32)) / np.maximum(np.asarray(b['scale'], np.float32), 1e-6) ).astype(np.float32) / np.sqrt(max(1, len(keep)))) return np.hstack(vs) if len(vs) > 1 else vs[0] EALL = embed(APCOLOR12) APTEAM = np.full(NAP, -1, np.int8) for i, g in enumerate(APGIDX.tolist()): t = teamdisk.get(int(g)) if t is not None: APTEAM[i] = t lcf = fta.get('meta', {}).get('autotune', {}).get('lastcalibfid') LASTCALIBFID = int(lcf) if lcf is not None else None with open(gkp, encoding='utf-8') as f: GKFIN = json.load(f) GKEXCLUDEDGIDX, GKCUTGIDX, GKOWNERMAP = set(), set(), {} for t in GKFIN['tracks']: for fr in t['frames']: g = fr.get('gidx') if g is None: continue GKEXCLUDEDGIDX.add(int(g)); GKOWNERMAP[int(g)] = f"GK{t['side']}" for c in GKFIN.get('cutlog', []): GKCUTGIDX.add(int(c['gidx'])) isgkexcl = (np.isin(APGIDX, np.asarray(sorted(GKEXCLUDEDGIDX)), np.int64) if GKEXCLUDEDGIDX else np.zeros(NAP, bool)) iscut = (np.isin(APGIDX, np.asarray(sorted(GKCUTGIDX)), np.int64) if GKCUTGIDX else np.zeros(NAP, bool)) FIELDPOOL = np.isin(APCLS, [CLSPLAYER, CLSGK]) & (~isgkexcl) & (~iscut) \ & (APCLS != CLSREF) with open(repp, encoding='utf-8') as f: rep = json.load(f) KFIELD = {int(k): max(1, int(v)) for k, v in rep['kest']['kbyteam'].items()} KTOTALFIELD = int(rep['kest']['ktotal']) o = np.argsort(APFID, kind='stable'); f = APFID[o] u, s = np.unique(f, returnindex=True); e = np.append(s[1:], len(f)) APFRAMEROWS = {int(u): np.sort(o[s:e]) for u, s, e in zip(u, s, e)} FIELDFRAMEROWS = {int(f): APFRAMEROWS[int(f)][FIELDPOOL[APFRAMEROWS[int(f)]]] for f in APFRAMEROWS}

KFIELD = {int(k): max(1, int(v)) for k, v in KFIELD.items()} NAP = len(APGIDX) APROW = {int(g): i for i, g in enumerate(APGIDX.tolist())} if VLGIDX is None: VLGIDX = np.zeros(0, np.int64); VLEMB = np.zeros((0, 0), np.float32) VLPOS = {int(g): i for i, g in enumerate(VLGIDX.tolist())} FIDS = sorted(FIELDFRAMEROWS.keys()) FPS = float(globals().get('VIDEOFPS', 25.0)) FIELDN = int(FIELDPOOL.sum())

if globals().get('FRAMEW') and globals().get('FRAMEH'): FW, FH = int(globals()['FRAMEW']), int(globals()['FRAMEH']) else: FW = int(np.percentile(APX2[FIELDPOOL], 99.9)) + 2 FH = int(np.percentile(APY2[FIELDPOOL], 99.9)) + 2

FRAMEMEDHW = {} for f in FIDS: rr = FIELDFRAMEROWS[f] FRAMEMEDHW[int(f)] = (float(np.median(APW[rr])), float(np.median(APH[rr]))) \ if len(_rr) else (np.nan, np.nan)

print(f"⚙️ {CELLTAG}: источник {SRC} | пул {FIELDN} ({FIELDN/len(FIDS):.2f}/кадр) | " f"K(28F, справочно)={KFIELD} -> K(доменный)={FIELDPLAYERSPERTEAM}/{FIELDPLAYERSPERTEAM} | " f"кадров {len(FIDS)} | кадр {FW}x{FH} | S4-капы: ТОЛЬКО ПОМЕТКА (балансировка в 31F) | " f"ОЗУ {ramgb():.2f} ГБ")

=====================================================

2. Утилиты

=====================================================

def iou_box(a, b): ix1, iy1 = max(a[0], b[0]), max(a[1], b[1]) ix2, iy2 = min(a[2], b[2]), min(a[3], b[3]) iw, ih = max(0.0, ix2 - ix1), max(0.0, iy2 - iy1) inter = iw ih if inter <= 0.0: return 0.0 ua = (a[2]-a[0])(a[3]-a[1]) + (b[2]-b[0])*(b[3]-b[1]) - inter return inter / max(1e-6, ua)

def boxoutsidefrac(box): ix1, iy1 = max(box[0], 0.0), max(box[1], 0.0) ix2, iy2 = min(box[2], float(FW)), min(box[3], float(FH)) inter = max(0.0, ix2 - ix1) max(0.0, iy2 - iy1) area = max((box[2]-box[0]) (box[3]-box[1]), 1e-6) return 1.0 - inter / area

def projoutsidexy(px, py): return (px < -PITCHMARGINM or px > 105.0 + PITCHMARGINM or py < -PITCHMARGINM or py > 68.0 + PITCHMARGINM)

class KF: def _init(self, x, y, vx, vy): self.s = np.array([x, y, vx, vy], np.float64) self.P = np.diag([1.0, 1.0, 9.0, 9.0]) self.H = np.array([[1., 0, 0, 0], [0, 1., 0, 0]], np.float64) self.R = np.eye(2) * KFRFIELD def clip(self): v = float(math.hypot(self.s[2], self.s[3])) if v > VMAXKF: self.s[2] *= VMAXKF / v; self.s[3] = VMAX_KF / v def predict(self, dt, damp=1.0): dt = max(dt, 1e-3) self.s[2] = damp; self.s[3] = damp F = np.array([[1, 0, dt, 0], [0, 1, 0, dt], [0, 0, 1, 0], [0, 0, 0, 1]], np.float64) Q = KF_Q_FIELD np.array([[dt4/4, 0, dt3/2, 0], [0, dt4/4, 0, dt3/2], [dt3/2, 0, dt2, 0], [0, dt3/2, 0, dt2]], np.float64) self.s = F @ self.s self.P = F @ self.P @ F.T + Q self.clip() def update(self, x, y): z = np.array([x, y], np.float64) S = self.H @ self.P @ self.H.T + self.R K = self.P @ self.H.T @ np.linalg.inv(S) self.s = self.s + K @ (z - self.H @ self.s) IKH = np.eye(4) - K @ self.H self.P = IKH @ self.P @ IKH.T + K @ self.R @ K.T self.clip() @property def pos(self): return float(self.s[0]), float(self.s[1]) @property def vel(self): return float(self.s[2]), float(self.s[3])

=====================================================

3. Класс трека

=====================================================

class FieldTrack: def _init(self, tid, seedrow, birthfid): self.tid = int(tid) self.bornfid = int(birthfid) self.rows = [int(seedrow)] self.frames = [] self.initrows = [int(seedrow)] self.votes = Counter() self.state = 'init' self.px = float(APPX[seedrow]); self.py = float(APPY[seedrow]) self.vx = 0.0; self.vy = 0.0 Ew = EALL[seedrow] self.proto = (Ew.astype(np.float32) if np.isfinite(Ew).all() else np.zeros(EALL.shape[1], np.float32)) self.vlproto = None self.hlist = [float(APH[seedrow])] self.wlist = [float(APW[seedrow])] self.lastbox = [float(APX1[seedrow]), float(APY1[seedrow]), float(APX2[seedrow]), float(APY2[seedrow])] self.lastcx = 0.5 (self.last_box[0] + self.last_box[2]) self.last_cy = 0.5 (self.lastbox[1] + self.lastbox[3]) self.lastpx = self.px; self.lastpy = self.py self.refreshmeds() self.kf = None self.team = None; self.teamdetfid = None self.loststreak = 0; self.totallost = 0; self.nrec = 0 self.veff = None; self.exitflag = False self.lostlog = []; self.lossopened = None self.overflowsuspect = False self.fidclaimed = set([int(birthfid)]) self.record(int(birthfid), seedrow) def refreshmeds(self): self.hmed = max(float(np.median(self.hlist[-MEDWIN:])), 1.0) self.wmed = max(float(np.median(self.wlist[-MEDWIN:])), 1e-3) def record(self, fid, r): box = [float(APX1[r]), float(APY1[r]), float(APX2[r]), float(APY2[r])] self.frames.append({'fid': int(fid), 'gidx': int(APGIDX[r]), 'bbox': [round(v, 1) for v in box], 'px': round(float(APPX[r]), 2), 'py': round(float(APPY[r]), 2), 'vx': (round(self.kf.vel[0], 2) if self.kf else 0.0), 'vy': (round(self.kf.vel[1], 2) if self.kf else 0.0)}) self.hlist.append(float(APH[r])); self.wlist.append(float(APW[r])) self.lastbox = box self.lastcx = 0.5 * (box[0] + box[2]); self.lastcy = 0.5 (box[1] + box[3]) self.last_px = float(AP_PX[r]); self.last_py = float(AP_PY[r]) def _record_init(self, fid, r): self.init_rows.append(int(r)) self.h_list.append(float(AP_H[r])); self.w_list.append(float(AP_W[r])) self.last_box = [float(AP_X1[r]), float(AP_Y1[r]), float(AP_X2[r]), float(AP_Y2[r])] self.last_cx = 0.5 (self.lastbox[0] + self.lastbox[2]) self.lastcy = 0.5 * (self.lastbox[1] + self.lastbox[3]) self.lastpx = float(APPX[r]); self.lastpy = float(APPY[r]) self.refreshmeds() def assign(self, fid, row, dt): self.recordinit(fid, row) xnew, ynew = float(APPX[row]), float(APPY[row]) a = 0.45 nvx = (xnew - self.px) / max(dt, 1e-3) nvy = (ynew - self.py) / max(dt, 1e-3) self.vx = a * nvx + (1 - a) * self.vx self.vy = a * nvy + (1 - a) * self.vy self.px = xnew; self.py = ynew e = EALL[row] if np.isfinite(e).all(): self.proto = ((1 - EMACOLALPHA) * self.proto

  • EMACOLALPHA e).astype(np.float32) g = int(AP_GIDX[row]) if g in VL_POS: ve = VL_EMB[VL_POS[g]] v = ve / max(1e-6, float(np.linalg.norm(ve))) if self.vl_proto is None: self.vl_proto = v.astype(np.float32) else: self.vl_proto = (0.7 self.vlproto + 0.3 * v) self.vlproto = (self.vlproto / max(1e-6, float(np.linalg.norm(self.vlproto)))).astype(np.float32) self.fidclaimed.add(int(fid)) def finalizeinit(self): rows = sorted(self.initrows, key=lambda r: int(APFID[r])) fidsw = [int(APFID[r]) for r in rows] px = float(np.median([float(APPX[r]) for r in rows[-6:]])) py = float(np.median([float(APPY[r]) for r in rows[-6:]])) disp = [] for a, b in zip(rows[:-1], rows[1:]): dtf = (int(APFID[b]) - int(APFID[a])) / FPS if dtf > 0: disp.append(((float(APPX[b]) - float(APPX[a])) / dtf, (float(APPY[b]) - float(APPY[a])) / dtf)) vx = float(np.median([d[0] for d in disp])) if disp else 0.0 vy = float(np.median([d[1] for d in disp])) if disp else 0.0 self.kf = KF(px, py, vx, vy) Es = EALL[rows] Es = Es[np.isfinite(Es).all(1)] if len(Es): self.proto = Es.mean(axis=0).astype(np.float32) self.frames = [] self.hlist = []; self.wlist = [] for r in rows: self.record(int(APFID[r]), r) self.refreshmeds() labs = [int(APTEAM[r]) for r in rows if int(APTEAM[r]) in (0, 1)] if len(labs) >= SELSTREAKTEAM and len(set(labs)) == 1: self.team = int(labs[0]); self.teamdetfid = int(fidsw[-1]) for t in labs: self.votes[t] += 1 self.state = 'active' self.lastprocfid = int(fidsw[-1]) self.fidclaimed = set(fidsw) def push(self, fid, r): if int(fid) in self.fidclaimed: return False self.rows.append(int(r)) self.initrows.append(int(r)) self.record(fid, r) self.refreshmeds() self.fidclaimed.add(int(fid)) return True def gates(self, r, active, d): h = float(APH[r]) if abs(math.log(h / self.hmed)) > SIZELOGMAX: return False wg = WGROUPBASE if self.hmed >= WGROUPHTHR else WGROUPSMALL if float(APW[r]) > wg * self.wmed: return False if active: iou = ioubox(self.lastbox, [float(APX1[r]), float(APY1[r]), float(APX2[r]), float(APY2[r])]) cx = 0.5 * (float(APX1[r]) + float(APX2[r])) cy = 0.5 * (float(APY1[r]) + float(APY2[r])) cd = math.hypot(cx - self.lastcx, cy - self.lastcy) if not (iou >= 0.1 or cd <= 60.0 or d <= 1.0): return False return True def colcost(self, r): comps = [] e = EALL[r] if np.isfinite(e).all(): comps.append(float(np.linalg.norm(e - self.proto)) / TMATCHCOL) g = int(APGIDX[r]) if self.vlproto is not None and g in VLPOS: ve = VLEMB[VLPOS[g]] cosv = float(np.dot(ve, self.vlproto)) comps.append(((1.0 - cosv) / 2.0) / TVLMATCH) if not comps: return NOCOL_COST return float(np.clip(float(np.mean(comps)), 0.0, 1.5))

=====================================================

4. S1: рождение якорей на f0

=====================================================

F0 = FIDS[0] rowsf0 = list(FIELDFRAMEROWS.get(F0, [])) good, rejg5b, rejh = [], 0, 0 if rowsf0: fmw, fmh = FRAMEMEDHW.get(F0, (np.nan, np.nan)) for r in rowsf0: if not APPROJ[r] or not np.isfinite(APPX[r]) or not np.isfinite(APPY[r]): continue if float(APH[r]) < BIRTHMINH: rejh += 1 continue if np.isfinite(fmw) and float(APW[r]) > WGROUPBASE * fmw \ and float(APH[r]) <= 1.2 * fmh: rejg5b += 1 continue good.append(r) good.sort(key=lambda r: -float(APCONF[r])) if len(good) > KTOTALFIELD: good = good[:KTOTALFIELD] tracks = {} owner = {} initowner = {} sellog = [] for i, r in enumerate(good): tr = FieldTrack(i, r, F0) tracks[i] = tr owner[int(r)] = i initowner[int(r)] = i sellog.append({'tid': i, 'gidx': int(APGIDX[r]), 'conf': round(float(APCONF[r]), 3), 'pos': (round(float(APPX[r]), 1), round(float(APPY[r]), 1)), 'h': round(float(APH[r]), 1), 'src': 'f0'}) print(f"🌱 S1: на f{F0} родилось якорей: {len(tracks)} " f"(детекций пула: {len(rowsf0)} | исключено: G5b-широких {rejg5b}, " f"мелких {rejh}, за лимитом {max(0, len(good) - KTOTALFIELD)})") for e in sel_log: print(f" ✅ tid={e['tid']:2d}: gidx={e['gidx']} conf={e['conf']:.2f} " f"pos=({e['pos'][0]},{e['pos'][1]}) h={e['h']}px")

=====================================================

5. S2: развитие Венгром + ДОБОР

=====================================================

print(f"▶️ S2: развитие Венгром + добор (f{F0+1}..f{F0+INITFRAMES})...") ts2 = time.perfcounter() nassigneds2 = 0 rebirthlog = [] freehist = {F0: set(int(r) for r in rowsf0 if int(r) not in initowner)} tid_seq = len(tracks)

def chainback(seedrow, birthfid): chain = [] headx, heady = float(APPX[seedrow]), float(APPY[seedrow]) gap = 0 for fid in range(birthfid - 1, F0 - 1, -1): if gap > REBIRTHCHAINGAP: break best, bd = None, REBIRTHCHAINM for r in freehist.get(fid, ()): d = math.hypot(float(APPX[r]) - headx, float(APPY[r]) - heady) if d < bd: bd, best = d, r if best is not None: chain.append((fid, int(best))) headx, heady = float(APPX[best]), float(AP_PY[best]) gap = 0 else: gap += 1 return chain[::-1]

for kstep in range(1, INITFRAMES + 1): fid = F0 + kstep if fid not in FIELDFRAMEROWS: break dt = 1.0 / FPS allrows = [int(r) for r in FIELDFRAMEROWS[fid]] freerows = [r for r in allrows if r not in initowner] tids = list(tracks.keys()) if not tids: break M = np.full((len(tids), len(freerows) + 1), 1e6, np.float64) M[:, len(freerows)] = 10.0 for ti, tid in enumerate(tids): tr = tracks[tid] prx = tr.px + tr.vx * dt pry = tr.py + tr.vy * dt for ci, r in enumerate(freerows): d = math.hypot(float(APPX[r]) - prx, float(APPY[r]) - pry) if d > INITGATEM: continue if abs(math.log(max(float(APH[r]), 1.0) / tr.hmed)) > SIZELOGMAX: continue e = EALL[r] col = (float(np.linalg.norm(e - tr.proto)) / TMATCHCOL if np.isfinite(e).all() else 1.0) cost = WINITPOS (d / INIT_GATE_M) + W_INIT_COL min(col, 1.5) if cost <= 1.6: M[ti, ci] = cost ri, ci = linearsumassignment(M) nhung = 0 for a, b in zip(ri, ci): if b < len(freerows) and M[a, b] < 1e6: tid = tids[a] r = freerows[b] tracks[tid].assign(fid, r, dt) initowner[int(r)] = tid owner[int(r)] = tid nassigneds2 += 1 nhung += 1 freenow = set(r for r in allrows if r not in initowner) fmw, fmh = FRAMEMEDHW.get(fid, (np.nan, np.nan)) if len(tracks) < KTOTALFIELD and freenow: for r in sorted(freenow, key=lambda q: -float(APCONF[q])): if len(tracks) >= KTOTALFIELD: break if not APPROJ[r] or not np.isfinite(APPX[r]) or not np.isfinite(APPY[r]): continue if float(APH[r]) < BIRTHMINH: continue if np.isfinite(fmw) and float(APW[r]) > WGROUPBASE fmw \ and float(AP_H[r]) <= 1.2 fmh: continue x, y = float(APPX[r]), float(APPY[r]) tooclose = False for tr in tracks.values(): prx = tr.px + tr.vx * dt pry = tr.py + tr.vy * dt if math.hypot(x - prx, y - pry) < REBIRTHMINDIST: tooclose = True break if tooclose: continue haschain = False for fprev in range(fid - 1, fid - 1 - REBIRTHCHAINGAP, -1): for rprev in freehist.get(fprev, ()): if math.hypot(x - float(APPX[rprev]), y - float(APPY[rprev])) <= REBIRTHCHAINM: haschain = True break if haschain: break if not haschain: continue trnew = FieldTrack(tidseq, r, fid) back = chainback(r, fid) for fprev, rprev in back: trnew.initrows.append(int(rprev)) trnew.hlist.append(float(APH[rprev])) trnew.wlist.append(float(APW[rprev])) trnew.fidclaimed.add(int(fprev)) initowner[int(rprev)] = tidseq owner[int(rprev)] = tidseq freehist[fprev].discard(int(rprev)) tracks[tidseq] = trnew initowner[int(r)] = tidseq owner[int(r)] = tidseq rebirthlog.append({'tid': tidseq, 'fid': fid, 'pos': (round(x, 1), round(y, 1)), 'conf': round(float(APCONF[r]), 3), 'back': len(back)}) print(f" ➕ ДОБОР: tid={tidseq} на f{fid} из ({x:.1f},{y:.1f}) " f"conf={float(APCONF[r]):.2f} | назад-цепочка: +{len(back)} дет.") tidseq += 1 freenow.discard(int(r)) freehist[fid] = freenow if kstep <= 3: print(f" [f{fid}] Венгр: {nhung} | якорей {len(tracks)} | " f"свободных {len(freenow)}") print(f"▶️ S2 завершена: назначений Венгра {nassigneds2} | доборов " f"{len(rebirthlog)} за {time.perfcounter()-t_s2:.1f}s")

=====================================================

6. S3: фиксация + распуск мёртвых + R3-отсев

=====================================================

dissolved = [] for tid in list(tracks.keys()): tr = tracks[tid] if len(tr.initrows) < 2: for r in tr.initrows: initowner.pop(int(r), None) owner.pop(int(r), None) dissolved.append(tid) del tracks[tid] else: tr.finalizeinit() evicted = [] while len(tracks) > KTOTALFIELD: weakest = None wkey = None for tid, tr in tracks.items(): medw = float(np.median(tr.wlist)) if tr.wlist else 0.0 key = (len(tr.initrows), -medw) if wkey is None or key < wkey: wkey, weakest = key, tid tr = tracks.pop(weakest) for r in tr.initrows: initowner.pop(int(r), None) owner.pop(int(r), None) evicted.append({'tid': weakest, 'ninit': len(tr.initrows)}) print(f" ⚖️ R3-отсев: распущен tid={weakest} " f"({len(tr.initrows)} init-дет.) — превышение {KTOTALFIELD}") print(f"🔒 S3: зафиксировано якорей {len(tracks)} | распущено мёртвых " f"{len(dissolved)} | отсеяно при переполнении {len(evicted)}") for tid in sorted(tracks.keys()): tr = tracks[tid] fidsw = [int(APFID[r]) for r in tr.initrows] src = 'f0' if tr.bornfid == F0 else f'f{tr.bornfid}(dobor)' print(f" 🔒 tid={tid:2d} [{src}]: f{fidsw[0]}..{fidsw[-1]} " f"({len(fidsw)} дет.), " f"med=({np.median([float(APPX[r]) for r in tr.initrows]):.1f}," f"{np.median([float(APPY[r]) for r in tr.initrows]):.1f}), " f"v=({tr.kf.vel[0]:.1f},{tr.kf.vel[1]:.1f}), teamокна={tr.team}") assert len(tracks) > 0, "❌ Не осталось ни одного якоря." remap = {old: new for new, old in enumerate(sorted(tracks.keys()))} newtracks = {} for old, tr in tracks.items(): tr.tid = remap[old] newtracks[remap[old]] = tr tracks = newtracks owner = {r: remap[t] for r, t in owner.items()} for e in rebirthlog: e['tid'] = remap.get(e['tid'], e['tid']) tidseq = len(tracks)

=====================================================

7. S4: основной трекинг (F2: капы ТОЛЬКО помечают)

=====================================================

detlog, dislog, conflog = [], [], [] pophist = {}

def reconcilecaps(fid): """F2: растворение УДАЛЕНО — только overflowsuspect-пометка (балансировка в 31F).""" for t in (0, 1): dett = [tr for tr in tracks.values() if tr.team == t] if len(dett) <= FIELDPLAYERSPERTEAM: # F3: доменный 10 continue weakest = min(dett, key=lambda tr: len(tr.frames)) if not weakest.overflowsuspect: weakest.overflowsuspect = True dislog.append({'fid': int(fid), 'tid': weakest.tid, 'team': t, 'action': 'overflowsuspect', 'n_frames': len(weakest.frames), 'note': 'растворение запрещено (F2) — разбор в 31F'})

print(f"▶️ S4: основной трекинг {len(FIDS)} кадров (капы: только пометка)...") tloop = time.perfcounter() nassignedlast = 0 for ifid, fid in enumerate(FIDS): fid = int(fid) tf0 = time.perfcounter() rowsframe = FIELDFRAMEROWS.get(fid, []) nfreetr = 0 if tracks: freetr = [int(r) for r in rowsframe if int(r) not in owner and APPROJ[r] and np.isfinite(APPX[r]) and np.isfinite(APPY[r])] nfreetr = len(freetr) tids = list(tracks.keys()) candsper, candall, candidx = [], [], {} for tid in tids: tr = tracks[tid] dt = (fid - tr.lastprocfid) / FPS if tr.loststreak == 0: vabs = float(math.hypot(*tr.kf.vel)) R = RBASE + vabs * max(dt, 1e-3) tr.kf.predict(max(dt, 1e-3)) else: basev = tr.veff if tr.veff is not None else float(math.hypot(tr.kf.vel)) tr.v_eff = min(base_v KEXPAND, VMAXFIELD) N = tr.loststreak + 1 R = min(RBASE + tr.veff * N * max(dt, 1e-3), RCAP) tr.kf.predict(max(dt, 1e-3), damp=DAMPLOST) tr.R = R prx, pry = tr.kf.pos vxkf, vykf = tr.kf.vel vnorm = math.hypot(vxkf, vykf) active = (tr.loststreak == 0) lst = [] for r in freetr: d = math.hypot(float(APPX[r]) - prx, float(APPY[r]) - pry) if d > R or d < 1e-9: continue if not tr.gates(r, active, d): continue if vnorm >= DIRMINSPEED: ux = (float(APPX[r]) - prx) / d uy = (float(APPY[r]) - pry) / d cosv = (vxkf * ux + vykf uy) / v_norm dirc = (1.0 - cosv) / 2.0 else: dirc = 0.5 c = W_POS (d / R) + WDIR * dirc + WCOL tr.col_cost(r) if tr.exit_flag: c = EXITDISCOUNT lst.append((r, c)) candsper.append(lst) for r, c in lst: if r not in candidx: candidx[r] = len(candall) candall.append(r) tr.lastprocfid = fid if len(tids) > 1 and candall: cc = Counter() for lst in candsper: for r, c in lst: cc[r] += 1 for r, n in cc.items(): if n > 1: conflog.append({'fid': fid, 'gidx': int(APGIDX[r]), 'n': int(n)}) nassignedlast = 0 if candall: ta0 = time.perfcounter() M = np.full((len(tids), len(candall)), 1e6, np.float64) for ti, lst in enumerate(candsper): for r, c in lst: M[ti, candidx[r]] = c ri, ci = linearsumassignment(M) ta = time.perfcounter() - ta0 if ta > HUNGSLOWS: print(f"⚠️ Венгр f{fid}: {ta:.2f}s, матрица {len(tids)}x{len(candall)}") assigned = {} for a, b in zip(ri, ci): if M[a, b] <= CMAX: assigned[a] = candall[b] nassignedlast = len(assigned) else: assigned = {} popt = {0: 0, 1: 0, None: 0} for ti, tid in enumerate(tids): tr = tracks[tid] if ti in assigned: r = assigned[ti] tr.kf.update(float(APPX[r]), float(APPY[r])) e = EALL[r] if np.isfinite(e).all(): tr.proto = ((1 - EMACOLALPHA) * tr.proto

  • EMACOLALPHA e).astype(np.float32) g = int(AP_GIDX[r]) if g in VL_POS: ve = VL_EMB[VL_POS[g]] if tr.vl_proto is None: tr.vl_proto = (ve / max(1e-6, float(np.linalg.norm(ve)))).astype(np.float32) else: tr.vl_proto = ((1 - EMA_VL_ALPHA) tr.vl_proto
  • EMAVLALPHA ve) tr.vl_proto = (tr.vl_proto / max(1e-6, float(np.linalg.norm(tr.vl_proto)))).astype(np.float32) t = int(AP_TEAM[r]) if t in (0, 1): tr.votes[t] += 1 owner[int(r)] = tid tr._push(fid, r) if tr.lost_streak > 0: tr.n_rec += 1 tr.lost_log.append({'from': int(tr._loss_opened), 'to': int(fid), 'dur': int(tr.lost_streak), 'R': round(tr._R, 2)}) tr.lost_streak = 0; tr.v_eff = None; tr.exit_flag = False pop_t[tr.team] = pop_t.get(tr.team, 0) + 1 else: if tr.lost_streak == 0: tr._loss_opened = fid tr.v_eff = float(math.hypot(tr.kf.vel)) tr.exitflag = bool(boxoutsidefrac(tr.lastbox) >= EXITAREAFRAC and projoutsidexy(tr.lastpx, tr.lastpy)) tr.loststreak += 1 tr.totallost += 1 pophist[fid] = popt detnow = 0 for tid, tr in tracks.items(): if tr.team is None: v0, v1 = tr.votes.get(0, 0), tr.votes.get(1, 0) tot = v0 + v1 if tot >= VOTEDETMIN and max(v0, v1) >= VOTEDETMAJ * tot: tr.team = 0 if v0 >= v1 else 1 tr.teamdetfid = fid detlog.append({'fid': int(fid), 'tid': int(tid), 'team': int(tr.team), 'v0': int(v0), 'v1': int(v1)}) detnow += 1 dorec = bool(detnow) or (LASTCALIBFID is not None and LASTCALIBFID < fid <= CAPRECHECKUNTIL and fid % CAPRECHECKEVERY == 0) if dorec: reconcilecaps(fid) if fid % 100 == 0: print(f" [f{fid}] {time.perfcounter()-tloop:6.1f}s | треков {len(tracks):2d} | " f"закреплено {len(owner)} | ассоц. {nassignedlast:2d} | " f"ОЗУ {ramgb():.1f} ГБ") print(f"▶️ S4 завершён за {time.perfcounter()-tloop:.1f}s")

=====================================================

8. Инварианты I1–I4

=====================================================

assert all(tr.bornfid <= F0 + INITFRAMES for tr in tracks.values()), \ "❌ I1: якорь рождён вне окна инициализации" assert len(tracks) <= KTOTALFIELD, "❌ I2: треков больше K" rowsowned = np.asarray(sorted(owner.keys()), np.int64) assert FIELDPOOL[rowsowned].all(), "❌ I3: закреплена детекция вне пула" if len(rowsowned): gkbad = np.isin(APGIDX[rowsowned], np.asarray(sorted(GKEXCLUDEDGIDX | GKCUTGIDX), np.int64)) assert not bool(gkbad.any()), "❌ I3: GK-детекция в полевом треке" for tr in tracks.values(): ff = [f['fid'] for f in tr.frames] assert len(ff) == len(set(ff)), f"❌ I4: две ассоциации в кадре (tid={tr.tid})" print(f"✅ Инварианты I1–I4: OK (якорей {len(tracks)} ≤ {KTOTALFIELD}, " f"окно рождения f{F0}..f{F0+INIT_FRAMES})")

=====================================================

9. Диагностика

=====================================================

if detlog: fds = [d['fid'] for d in detlog] print(f"\n🏷 Команды якорей: определены {len(detlog)}/{len(tracks)} " f"(f{min(fds)}..{max(fds)})") undet = [tr.tid for tr in tracks.values() if tr.team is None] if undet: print(f" ⚠️ Неопределённые: tid {undet} — разрешатся в 31F") if dislog: print(f"⚖️ Капы (только пометки, БЕЗ растворения): {len(dislog)}") for d in dislog: # F3: детали print(f" 🔶 f{d['fid']} tid={d['tid']} team=t{d['team']} " f"overflowsuspect (n={d['nframes']}) — разбор в 31F") else: print("⚖️ Капы: переполнений нет") print(f"⚔️ Конфликтов Венгра: {len(conflog)}") nt0 = sum(1 for tr in tracks.values() if tr.team == 0) nt1 = sum(1 for tr in tracks.values() if tr.team == 1) print(f"👥 Проход 1: t0={nt0}, t1={nt1}, без команды {len(tracks) - nt0 - n_t1} " f"| доменная цель 10/10 — балансировка в 31F (mixed-треки + приор)")

print(f"\n📋 Треки (проход 1):") spans, covs, purs = [], [], [] swapsuspects = [] for tr in sorted(tracks.values(), key=lambda t: -len(t.frames)): fidst = [f['fid'] for f in tr.frames] span = fidst[-1] - fidst[0] + 1 cov = len(fidst) / span dgs = [float(np.linalg.norm(EALL[r] - tr.proto)) for r in tr.initrows if np.isfinite(EALL[r]).all()] pur = float(np.mean(np.asarray(dgs) <= TMATCHCOL)) if dgs else None spans.append(span); covs.append(cov) if pur is not None: purs.append(pur) v0, v1 = tr.votes.get(0, 0), tr.votes.get(1, 0) if min(v0, v1) >= 3 and min(v0, v1) / max(v0, v1) >= 0.3: swapsuspects.append(tr.tid) bsrc = 'f0' if tr.bornfid == F0 else f'b{tr.bornfid}' print(f" tid={tr.tid:2d} team={str(tr.team):>4} [{bsrc}] f{fidst[0]}..{fidst[-1]} " f"n={len(fidst):3d} span={span:3d} cov={cov:.2f} " f"purity={pur if pur is None else round(pur, 3)} " f"голоса t0:{v0}/t1:{v1} потерь:{tr.totallost} восст:{tr.nrec}" f"{' 🔶overflowsuspect' if tr.overflowsuspect else ''}") print(f"\n Медиана длительности: {float(np.median(spans)):.0f} (цель ≥400) | " f"медиана coverage: {float(np.median(covs)):.2f} | " f"медиана purity: {float(np.median(purs)) if purs else '—'}") if swapsuspects: print(f" ⚠️ Подозрения на своп (кандидаты 31F для разбора): tid {swapsuspects}")

=====================================================

10. Экспорт

=====================================================

FIELDTRACKSFWD = [] for tr in sorted(tracks.values(), key=lambda t: -len(t.frames)): fidst = [f['fid'] for f in tr.frames] dgs = [float(np.linalg.norm(EALL[r] - tr.proto)) for r in tr.initrows if np.isfinite(EALL[r]).all()] FIELDTRACKSFWD.append({ 'tid': int(tr.tid), 'team': (int(tr.team) if tr.team is not None else None), 'teamdetfid': tr.teamdetfid, 'bornfid': int(tr.bornfid), 'fidstart': int(fidst[0]), 'fidend': int(fidst[-1]), 'nframes': len(fidst), 'frames': tr.frames, 'protoe': tr.proto.tolist(), 'vlproto': (tr.vlproto.tolist() if tr.vlproto is not None else None), 'votes': {'0': int(tr.votes.get(0, 0)), '1': int(tr.votes.get(1, 0))}, 'overflowsuspect': bool(tr.overflowsuspect), 'lostlog': tr.lostlog, 'birth': {'mode': 'jointinitv2.2', 'src': ('f0' if tr.bornfid == F0 else 'rebirth'), 'seedgidx': int(APGIDX[tr.initrows[0]]), 'initframes': len(tr.initrows)}, 'stats': {'span': int(fidst[-1] - fidst[0] + 1), 'coverage': round(len(fidst) / (fidst[-1] - fidst[0] + 1), 3), 'puritytmatch': (round(float(np.mean(np.asarray(dgs) <= TMATCHCOL)), 3) if dgs else None), 'nlost': int(tr.totallost), 'nrecovered': int(tr.nrec)}}) FWDGIDXOWNER = {int(f['gidx']): int(t['tid']) for t in FIELDTRACKSFWD for f in t['frames']}

payload = {'meta': {'cell': CELLTAG, 'src': SRC, 'fps': FPS, 'params': {'INITFRAMES': INITFRAMES, 'INITGATEM': INITGATEM, 'WINITPOS': WINITPOS, 'WINITCOL': WINITCOL, 'BIRTHMINH': BIRTHMINH, 'REBIRTHMINDIST': REBIRTHMINDIST, 'REBIRTHCHAINM': REBIRTHCHAINM, 'REBIRTHCHAINGAP': REBIRTHCHAINGAP, 'FIELDPLAYERSPERTEAM': FIELDPLAYERSPERTEAM, 'TMATCHCOL': TMATCHCOL, 'VMAXFIELD': VMAXFIELD, 'VMAXKF': VMAXKF, 'RBASE': RBASE, 'KEXPAND': KEXPAND, 'RCAP': RCAP, 'CMAX': CMAX, 'WPOS': WPOS, 'WDIR': WDIR, 'WCOL': WCOL, 'SIZELOGMAX': SIZELOGMAX, 'DAMPLOST': DAMPLOST, 'EMACOLALPHA': EMACOLALPHA, 'EXITDISCOUNT': EXITDISCOUNT, 'capsmode': 'markonly (балансировка в 31F)'}, 'kfield28f': {str(k): int(v) for k, v in KFIELD.items()}, 'kdomain': FIELDPLAYERSPERTEAM, 'ktotal': int(KTOTALFIELD), 'ntracks': len(FIELDTRACKSFWD), 'nbornf0': len(sellog), 'nrebirths': len(rebirthlog), 'ndissolvedinit': len(dissolved), 'nevicted': len(evicted), 'ns2assignments': nassigneds2, 'ndetermined': len(detlog), 'ncapevents': len(dislog), 'nconflicts': len(conflog), 'teamspass1': {'t0': nt0, 't1': nt1}, 'medianspan': float(np.median(spans)), 'mediancoverage': float(np.median(covs))}, 'tracks': FIELDTRACKSFWD, 'sellog': sellog, 'rebirthlog': rebirthlog, 'detlog': detlog, 'caplog': dislog, 'conflicts': conflog} with open(FWDPATH, 'w', encoding='utf-8') as f: json.dump(payload, f, ensureascii=False, separators=(',', ':')) print(f"\n💾 {FWDPATH}") print(f" Глобали: FIELDTRACKSFWD ({len(FIELDTRACKSFWD)}), FWDGIDXOWNER " f"({len(FWDGIDXOWNER)}) | ОЗУ {ramgb():.2f} ГБ") gc.collect() print(f"\n✅ {CELLTAG} готов ({time.perfcounter() - t00:.1f} c). " "Следующие: 30F v2 (без изменений) -> 31F v5.1 -> 31F v7 -> 32F v4.")

@title 30F v2. Этап 3: обратный трекинг с командами + отложенные якоря (ФИКС I4)

#

v2 = 30F + ОДНО ИСПРАВЛЕНИЕ (остальное без изменений):

[F1] КОРЕНЬ AssertionError I4 (tid=1, f749): инициализация fwd-якорей (сеяние

состояния по fwd-хвосту) стояла ПЕРЕД трекингом кадра. Для якоря с

fid_end == первому кадру реверса (749) хвост инициализации содержал строку

f749, и первый же Венгр назначал её повторно -> две ассоциации в кадре.

Фикс: инициализация перенесена в шаг (г) ПОСЛЕ трекинга кадра — на кадре

fid_end трек ещё не участвует в трекинге (нет дубля), а со следующего кадра

вниз ассоциирует штатно. Допзащита: _push игнорирует повторную строку того

же кадра (строки хвоста f<fid_end записаны при инициализации как контекст,

повторно не претендуются — их перепроверка остаётся арбитражу 31F).

Унаследовано: B7-семантика (хвост SEL_WINDOW, без претендования), командный фильтр

ТЗ 7.1 (чужая команда — INF), зеркальная механика (KFB dt<0, зеркальный косинус,

Венгр C_MAX, exit-скидка), отложенные якоря В2 (>=25 кадров, капы, гейты рождения).

Самодостаточна после рестарта (cache + fieldtracksfwd.json).

import os, gc, json, time, math import numpy as np from collections import Counter, defaultdict from scipy.optimize import linearsumassignment

t00 = time.perfcounter() CELLTAG = '30F v2'

=====================================================

0. ОЗУ-монитор + очистка

=====================================================

def ramgb(): try: with open('/proc/meminfo') as f: for line in f: if line.startswith('MemAvailable:'): return int(line.split()[1]) / 1e6 except Exception: return float('nan') return float('nan')

ram0 = ramgb() try: import matplotlib.pyplot as plt plt.close('all') except Exception: pass try: import torch as torch hastorch = True except Exception: hastorch = False

HEAVY = ['model', 'FIELDMODEL', 'SEGMODEL', 'VLMODEL', 'VLPROCESSOR', 'BASELINETRACKS', 'GKPFRAMES', 'GKCFRAMES', 'kpscache', 'viscache', 'framesbyid', 'balltrack', 'playersbyframe'] freed = [] for n in HEAVY: if n in globals() and globals()[n] is not None: del globals()[n] freed.append(n) gc.collect() if hastorch and torch.cuda.isavailable(): torch.cuda.emptycache() print(f"🧹 Очистка: удалены {freed if freed else '—'} | ОЗУ доступно: " f"{ram0:.2f} -> {ramgb():.2f} ГБ")

================== КОНСТАНТЫ ТЗ (из 28F/29F) ==================

SELWINDOW = int(globals().get('SELWINDOW', 6)) KSELWINDOW = int(globals().get('KSELWINDOW', 6)) TMATCHCOL = float(globals().get('TMATCHCOL', 1.5)) TVLMATCH = float(globals().get('TVLMATCH', 0.30)) VMAXFIELD = float(globals().get('VMAXFIELD', 10.0)) VMAXKF = float(globals().get('VMAXKF', 9.0)) RBASE = float(globals().get('RBASE', 2.5)) KEXPAND = float(globals().get('KEXPAND', 1.15)) RCAP = float(globals().get('RCAP', 40.0)) CMAX = float(globals().get('CMAX', 1.2)) WPOS = float(globals().get('WPOS', 0.5)) WDIR = float(globals().get('WDIR', 0.2)) WCOL = float(globals().get('WCOL', 0.3)) SIZELOGMAX = float(globals().get('SIZELOGMAX', 0.7)) WGROUPBASE = float(globals().get('WGROUPBASE', 1.4)) WGROUPSMALL = float(globals().get('WGROUPSMALL', 1.7)) WGROUPHTHR = float(globals().get('WGROUPHTHR', 60.0)) SELSTREAKTEAM = int(globals().get('SELSTREAKTEAM', 3))

--- локальные параметры 30F ---

BUDLINKR, BUDLINKGAP = 2.0, 2 DEFERWINDOW = 30 DEFERMINFIDS = 25 BIRTHMINH = 15.0 EXITAREAFRAC = 0.30 PITCHMARGINM = 3.0 DIRMINSPEED = 0.3 NOCOLCOST = 0.75 EMACOLALPHA = 0.30 EMAVLALPHA = 0.30 DAMPLOST = 0.90 EXITDISCOUNT = 0.85 KFQFIELD, KFRFIELD = 6.0, 0.20 MEDWIN = 150 FRAMESLOWS = 3.0 HUNGSLOWS = 0.5

CLSGK, CLSPLAYER, CLSREF = (int(globals().get('CLSGK', 1)), int(globals().get('CLSPLAYER', 2)), int(globals().get('CLSREF', 3))) CACHEDIR = str(globals().get('CACHEDIR', '/content/cache')) OUTPUTDIR = str(globals().get('OUTPUTDIR', '/content/output')) FWDPATH = os.path.join(OUTPUTDIR, 'fieldtracksfwd.json') BWDPATH = os.path.join(OUTPUTDIR, 'fieldtracksbwd.json')

=====================================================

1. Данные: память 29F | кэши с диска (+ fieldtracksfwd.json)

=====================================================

if ('FIELDTRACKSFWD' in globals() and globals()['FIELDTRACKSFWD'] and 'APGIDX' in globals() and globals()['APGIDX'] is not None): SRC = 'memory29F' APGIDX = globals()['APGIDX']; APFID = globals()['APFID'] APCLS = globals()['APCLS']; APCONF = globals()['APCONF'] APX1 = globals()['APX1']; APY1 = globals()['APY1'] APX2 = globals()['APX2']; APY2 = globals()['APY2'] APH = globals()['APH']; APW = globals()['APW'] APPX = globals()['APPX']; APPY = globals()['APPY']; APPROJ = globals()['APPROJ'] APTEAM = globals()['APTEAM'] EALL = np.asarray(globals()['EALL'], np.float32) VLGIDX = globals().get('VLGIDX'); VLEMB = globals().get('VLEMB') FIELDPOOL = globals()['FIELDPOOL'] FIELDFRAMEROWS = globals()['FIELDFRAMEROWS'] KFIELD = {int(k): int(v) for k, v in globals()['KFIELD'].items()} KTOTALFIELD = int(globals()['KTOTALFIELD']) GKEXCLUDEDGIDX = globals()['GKEXCLUDEDGIDX'] GKCUTGIDX = globals().get('GKCUTGIDX', set()) FIELDTRACKSFWD = globals()['FIELDTRACKSFWD'] else: SRC = 'cache' ap = os.path.join(CACHEDIR, 'appearancecache.npz') ftap = os.path.join(OUTPUTDIR, 'frameteamassignment.json') protop = os.path.join(OUTPUTDIR, 'teamprototypes.json') gkp = os.path.join(OUTPUTDIR, 'gktracksfinal.json') repp = os.path.join(OUTPUTDIR, 'fieldstage0report.json') for p in (ap, ftap, protop, gkp, repp, FWDPATH): assert os.path.exists(p), f"❌ {p} не найден — выполните 28F и 29F." with np.load(ap) as z: APGIDX = z['gidx'].astype(np.int64); APFID = z['fid'].astype(np.int32) APCLS = z['cls'].astype(np.int8); APCONF = z['conf'].astype(np.float32) APX1 = z['x1'].astype(np.float32); APY1 = z['y1'].astype(np.float32) APX2 = z['x2'].astype(np.float32); APY2 = z['y2'].astype(np.float32) APH = z['h'].astype(np.float32); APW = z['w'].astype(np.float32) APPX = z['pitchx'].astype(np.float32); APPY = z['pitchy'].astype(np.float32) APPROJ = z['proj'].astype(bool) APCOLOR12 = z['color12'].astype(np.float32) APTEAM = z['team'].astype(np.int8) VLGIDX = (z['vlgidx'].astype(np.int64) if 'vlgidx' in z.files else np.zeros(0, np.int64)) VLEMB = (z['vlemb'].astype(np.float32) if 'vlemb' in z.files else np.zeros((0, 0), np.float32)) NAP = len(APGIDX) with open(ftap, encoding='utf-8') as f: fta = json.load(f) teamdisk = {} for recs in fta.get('frames', {}).values(): for r in recs: teamdisk[int(r['gidx'])] = int(r['team']) with open(protop, encoding='utf-8') as f: PROTO = json.load(f) BLOCKS = PROTO.get('blocks', []) def embed(c12): if not BLOCKS: return np.asarray(c12, np.float32) vs = [] for b in BLOCKS: s0, s1 = int(b['slice'][0]), int(b['slice'][1]) keep = np.asarray(b['keepdims'], int) part = c12[:, s0:s1][:, keep] vs.append(((part - np.asarray(b['mean'], np.float32)) / np.maximum(np.asarray(b['scale'], np.float32), 1e-6) ).astype(np.float32) / np.sqrt(max(1, len(keep)))) return np.hstack(vs) if len(vs) > 1 else vs[0] EALL = embed(APCOLOR12) APTEAM = np.full(NAP, -1, np.int8) for i, g in enumerate(APGIDX.tolist()): t = teamdisk.get(int(g)) if t is not None: APTEAM[i] = t with open(gkp, encoding='utf-8') as f: GKFIN = json.load(f) GKEXCLUDEDGIDX, GKCUTGIDX = set(), set() for t in GKFIN['tracks']: for fr in t['frames']: if fr.get('gidx') is not None: GKEXCLUDEDGIDX.add(int(fr['gidx'])) for c in GKFIN.get('cutlog', []): GKCUTGIDX.add(int(c['gidx'])) isgkexcl = (np.isin(APGIDX, np.asarray(sorted(GKEXCLUDEDGIDX), np.int64)) if GKEXCLUDEDGIDX else np.zeros(NAP, bool)) iscut = (np.isin(APGIDX, np.asarray(sorted(GKCUTGIDX), np.int64)) if GKCUTGIDX else np.zeros(NAP, bool)) FIELDPOOL = np.isin(APCLS, [CLSPLAYER, CLSGK]) & (~isgkexcl) & (~iscut) \ & (APCLS != CLSREF) with open(repp, encoding='utf-8') as f: rep = json.load(f) KFIELD = {int(k): max(1, int(v)) for k, v in rep['kest']['kbyteam'].items()} KTOTALFIELD = int(rep['kest']['ktotal']) o = np.argsort(APFID, kind='stable'); f = APFID[o] u, s = np.unique(f, returnindex=True); e = np.append(s[1:], len(f)) APFR = {int(u): np.sort(o[s:e]) for u, s, e in zip(u, s, e)} FIELDFRAMEROWS = {int(f): APFR[int(f)][FIELDPOOL[APFR[int(f)]]] for f in APFR} with open(FWDPATH, encoding='utf-8') as f: FIELDTRACKS_FWD = json.load(f)['tracks']

NAP = len(APGIDX) APROW = {int(g): i for i, g in enumerate(APGIDX.tolist())} if VLGIDX is None: VLGIDX = np.zeros(0, np.int64); VLEMB = np.zeros((0, 0), np.float32) VLPOS = {int(g): i for i, g in enumerate(VLGIDX.tolist())} FIDS = sorted(FIELDFRAMEROWS.keys()) FPS = float(globals().get('VIDEOFPS', 25.0))

if globals().get('FRAMEW') and globals().get('FRAMEH'): FW, FH = int(globals()['FRAMEW']), int(globals()['FRAMEH']) else: FW = int(np.percentile(APX2[FIELDPOOL], 99.9)) + 2 FH = int(np.percentile(APY2[FIELDPOOL], 99.9)) + 2

FRAMEMEDHW = {} for f in FIDS: rr = FIELDFRAMEROWS[f] FRAMEMEDHW[int(f)] = (float(np.median(APW[rr])), float(np.median(APH[rr]))) \ if len(_rr) else (np.nan, np.nan)

print(f"⚙️ {CELLTAG}: источник {SRC} | пул {int(FIELDPOOL.sum())} | " f"K={KFIELD} (всего {KTOTALFIELD}) | fwd-треков {len(FIELDTRACKSFWD)} | " f"кадров {len(FIDS)} | ОЗУ {ram_gb():.2f} ГБ")

=====================================================

2. Утилиты

=====================================================

def iou_box(a, b): ix1, iy1 = max(a[0], b[0]), max(a[1], b[1]) ix2, iy2 = min(a[2], b[2]), min(a[3], b[3]) iw, ih = max(0.0, ix2 - ix1), max(0.0, iy2 - iy1) inter = iw ih if inter <= 0.0: return 0.0 ua = (a[2]-a[0])(a[3]-a[1]) + (b[2]-b[0])*(b[3]-b[1]) - inter return inter / max(1e-6, ua)

def boxoutsidefrac(box): ix1, iy1 = max(box[0], 0.0), max(box[1], 0.0) ix2, iy2 = min(box[2], float(FW)), min(box[3], float(FH)) inter = max(0.0, ix2 - ix1) max(0.0, iy2 - iy1) area = max((box[2]-box[0]) (box[3]-box[1]), 1e-6) return 1.0 - inter / area

def projoutsidexy(px, py): return (px < -PITCHMARGINM or px > 105.0 + PITCHMARGINM or py < -PITCHMARGINM or py > 68.0 + PITCHMARGINM)

class KFB: """Kalman для обратного прохода: dt < 0 (Q по |dt|, кросс-члены со знаком dt).""" def _init(self, x, y, vx, vy): self.s = np.array([x, y, vx, vy], np.float64) self.P = np.diag([1.0, 1.0, 9.0, 9.0]) self.H = np.array([[1., 0, 0, 0], [0, 1., 0, 0]], np.float64) self.R = np.eye(2) * KFRFIELD def clip(self): v = float(math.hypot(self.s[2], self.s[3])) if v > VMAXKF: self.s[2] *= VMAXKF / v; self.s[3] = VMAX_KF / v def predict(self, dt, damp=1.0): dt = min(dt, -1e-3) a = abs(dt) self.s[2] = damp; self.s[3] = damp F = np.array([[1, 0, dt, 0], [0, 1, 0, dt], [0, 0, 1, 0], [0, 0, 0, 1]], np.float64) Q = KF_Q_FIELD np.array([[a4/4, 0, dt3/2, 0], [0, a4/4, 0, dt3/2], [dt3/2, 0, a2, 0], [0, dt3/2, 0, a2]], np.float64) self.s = F @ self.s self.P = F @ self.P @ F.T + Q self.clip() def update(self, x, y): z = np.array([x, y], np.float64) S = self.H @ self.P @ self.H.T + self.R K = self.P @ self.H.T @ np.linalg.inv(S) self.s = self.s + K @ (z - self.H @ self.s) IKH = np.eye(4) - K @ self.H self.P = IKH @ self.P @ IKH.T + K @ self.R @ K.T self.clip() @property def pos(self): return float(self.s[0]), float(self.s[1]) @property def vel(self): return float(self.s[2]), float(self.s[3])

=====================================================

3. Класс обратного трека (F1: _push с защитой от дубля кадра)

=====================================================

class BwdTrack: def _init(self, tid, team, initrows, proto, vlproto, deferred, initfid): self.tid = int(tid) self.team = (int(team) if team is not None else None) self.deferred = bool(deferred) self.initfid = int(initfid) self.rows = [] self.frames = [] self.votes = Counter() self.proto = np.asarray(proto, np.float32) self.vlproto = (np.asarray(vlproto, np.float32) if vlproto is not None else None) rows = sorted(int(r) for r in initrows) if self.deferred: edgerows = rows[:KSELWINDOW] else: edgerows = rows[-KSELWINDOW:] px = float(np.median(APPX[edgerows])); py = float(np.median(APPY[edgerows])) disp = [] for a, b in zip(rows[:-1], rows[1:]): dtf = (int(APFID[b]) - int(APFID[a])) / FPS if dtf > 0: disp.append(((float(APPX[b]) - float(APPX[a])) / dtf, (float(APPY[b]) - float(APPY[a])) / dtf)) vx = float(np.median([d[0] for d in disp])) if disp else 0.0 vy = float(np.median([d[1] for d in disp])) if disp else 0.0 self.kf = KFB(px, py, vx, vy) edgerow = edgerows[0] if self.deferred else edgerows[-1] self.lastprocfid = int(APFID[edgerow]) self.hlist = []; self.wlist = [] self.lastbox = None; self.lastcx = self.lastcy = 0.0 self.lastpx = self.lastpy = 0.0 if self.deferred: for r in rows: self.claim(int(APFID[r]), r) else: for r in rows: self.record(int(APFID[r]), r) # контекст, без претендования self.refreshmeds() self.loststreak = 0; self.totallost = 0; self.nrec = 0 self.veff = None; self.exitflag = False self.lostlog = []; self.lossopened = None self.fidclaimed = set(f['fid'] for f in self.frames) # F1: защита от дубля def refreshmeds(self): self.hmed = max(float(np.median(self.hlist[-MEDWIN:])), 1.0) self.wmed = max(float(np.median(self.wlist[-MEDWIN:])), 1e-3) def record(self, fid, r): box = [float(APX1[r]), float(APY1[r]), float(APX2[r]), float(APY2[r])] self.frames.append({'fid': int(fid), 'gidx': int(APGIDX[r]), 'bbox': [round(v, 1) for v in box], 'px': round(float(APPX[r]), 2), 'py': round(float(APPY[r]), 2), 'vx': round(self.kf.vel[0], 2), 'vy': round(self.kf.vel[1], 2)}) self.hlist.append(float(APH[r])); self.wlist.append(float(APW[r])) self.lastbox = box self.lastcx = 0.5 (box[0] + box[2]); self.last_cy = 0.5 (box[1] + box[3]) self.lastpx = float(APPX[r]); self.lastpy = float(APPY[r]) def claim(self, fid, r): self.rows.append(int(r)) ownerbwd[int(r)] = self.tid self.record(fid, r) self.fidclaimed.add(int(fid)) def push(self, fid, r): if int(fid) in self.fidclaimed: # F1: повторная строка кадра — игнор return False self.rows.append(int(r)) ownerbwd[int(r)] = self.tid self.record(fid, r) self.refreshmeds() self.fidclaimed.add(int(fid)) return True def gates(self, r, active, d): h = float(APH[r]) if abs(math.log(h / self.hmed)) > SIZELOGMAX: return False wg = WGROUPBASE if self.hmed >= WGROUPHTHR else WGROUPSMALL if float(APW[r]) > wg * self.wmed: return False if active: iou = ioubox(self.lastbox, [float(APX1[r]), float(APY1[r]), float(APX2[r]), float(APY2[r])]) cx = 0.5 * (float(APX1[r]) + float(APX2[r])) cy = 0.5 * (float(APY1[r]) + float(APY2[r])) cd = math.hypot(cx - self.lastcx, cy - self.lastcy) if not (iou >= 0.1 or cd <= 60.0 or d <= 1.0): return False return True def teamok(self, r): if self.team is None: return True t = int(APTEAM[r]) return (t not in (0, 1)) or (t == self.team) def colcost(self, r): comps = [] e = EALL[r] if np.isfinite(e).all(): comps.append(float(np.linalg.norm(e - self.proto)) / TMATCHCOL) g = int(APGIDX[r]) if self.vlproto is not None and g in VLPOS: ve = VLEMB[VLPOS[g]] cosv = float(np.dot(ve, self.vlproto)) comps.append(((1.0 - cosv) / 2.0) / TVLMATCH) if not comps: return NOCOL_COST return float(np.clip(float(np.mean(comps)), 0.0, 1.5))

=====================================================

4. Обратный проход

=====================================================

tracks = {} ownerbwd = {} buds = [] teamblocks = 0 deferredlog = [] deferredrej = Counter() tidnext = max(int(t['tid']) for t in FIELDTRACKSFWD) + 1

initat = defaultdict(list) for t in FIELDTRACKSFWD: initat[int(t['fid_end'])].append(t)

def evaldeferbud(b): rows = sorted(set(int(r) for r in b['rows']), key=lambda r: APFID[r]) seen = {} for r in rows: f = int(APFID[r]) if f not in seen or float(APCONF[r]) > float(APCONF[seen[f]]): seen[f] = r rows = [seen[f] for f in sorted(seen)] nf = len(rows) info = {'fidhi': int(b['fidhi']), 'fidlo': int(APFID[rows[0]]), 'nf': nf, 'n': len(b['rows'])} if nf < DEFERMINFIDS: return None, 'fewframes', info if len(tracks) >= KTOTALFIELD: return None, 'captotal', info medh = float(np.median(APH[rows])) info['medh'] = round(medh, 1) if medh < BIRTHMINH: return None, 'size', info nexit = sum(1 for r in rows if boxoutsidefrac([float(APX1[r]), float(APY1[r]), float(APX2[r]), float(APY2[r])]) >= EXITAREAFRAC and projoutsidexy(float(APPX[r]), float(APPY[r]))) if 2 n_exit > len(rows): return None, 'exit_zone', info med_w = float(np.median(AP_W[rows])) fmw, fmh = FRAME_MED_HW.get(int(b['fid_hi']), (np.nan, np.nan)) if np.isfinite(fmw) and med_w > W_GROUP_BASE fmw and medh <= 1.2 * fmh: return None, 'g5b', info labs = [int(APTEAM[r]) for r in rows if APTEAM[r] in (0, 1)] team = (int(labs[0]) if (len(labs) >= SELSTREAKTEAM and len(set(labs)) == 1) else None) info['team'] = team if team is not None and sum(1 for tr in tracks.values() if tr.team == team) >= KFIELD[team]: return None, 'cap_team', info return rows, team, info

print(f"▶️ Обратный проход: {len(FIDS)} кадров (с f{FIDS[-1]} вниз)...") tloop = time.perfcounter() nassignedlast = 0 for ifid, fid in enumerate(reversed(FIDS)): tf0 = time.perfcounter() fid = int(fid) rowsframe = FIELDFRAMEROWS.get(fid, [])

# --- (а) закрытие/оценка почек отложенных якорей --- closed, keep = [], [] for b in buds: stale = (b['lastfid'] - fid) > LINKGAP windowdone = fid <= b['fidhi'] - DEFERWINDOW + 1 if stale or windowdone: closed.append(b) else: keep.append(b) buds = keep for b in closed: drows, dteam, dinfo = evaldeferbud(b) if drows is None: deferredrej[dteam] += 1 if sum(1 for d in deferredlog if d.get('action') == 'rejected') <= 8: deferredlog.append({'action': 'rejected', 'reason': dteam, **dinfo}) continue Ew = EALL[drows] Ew = Ew[np.isfinite(Ew).all(1)] if len(Ew) == 0: deferredrej['nocolor'] += 1 continue proto = Ew.mean(axis=0).astype(np.float32) vlrows = [VLPOS[int(APGIDX[r])] for r in drows if int(APGIDX[r]) in VLPOS] vlproto = None if vlrows: v = VLEMB[vlrows].mean(axis=0) vlproto = (v / max(1e-6, float(np.linalg.norm(v)))).astype(np.float32) tr = BwdTrack(tidnext, dteam, drows, proto, vlproto, deferred=True, initfid=int(dinfo['fidlo'])) tracks[tr.tid] = tr deferredlog.append({'action': 'created', 'tid': int(tr.tid), 'team': dteam, **dinfo}) tidnext += 1

# --- (б) трекинг (с командным фильтром; НОВЫЕ якоря текущего кадра ещё не участвуют) --- nfreetr = 0 if tracks: freetr = [int(r) for r in rowsframe if int(r) not in ownerbwd and APPROJ[r] and np.isfinite(APPX[r]) and np.isfinite(APPY[r])] nfreetr = len(freetr) tids = list(tracks.keys()) candsper, candall, candidx = [], [], {} for tid in tids: tr = tracks[tid] dt = (fid - tr.lastprocfid) / FPS if tr.loststreak == 0: vabs = float(math.hypot(tr.kf.vel)) R = R_BASE + v_abs abs(min(dt, -1e-3)) tr.kf.predict(dt) else: basev = tr.veff if tr.veff is not None else float(math.hypot(*tr.kf.vel)) tr.veff = min(basev * KEXPAND, VMAXFIELD) N = tr.loststreak + 1 R = min(RBASE + tr.veff N abs(min(dt, -1e-3)), RCAP) tr.kf.predict(dt, damp=DAMPLOST) tr.R = R prx, pry = tr.kf.pos vxkf, vykf = tr.kf.vel vnorm = math.hypot(vxkf, vykf) active = (tr.loststreak == 0) lst = [] for r in freetr: if not tr.teamok(r): teamblocks += 1 continue d = math.hypot(float(APPX[r]) - prx, float(APPY[r]) - pry) if d > R or d < 1e-9: continue if not tr.gates(r, active, d): continue if vnorm >= DIRMINSPEED: ux = (prx - float(APPX[r])) / d uy = (pry - float(APPY[r])) / d cosv = (vxkf ux + vy_kf uy) / vnorm dirc = (1.0 - cosv) / 2.0 else: dirc = 0.5 c = WPOS (d / R) + W_DIR dirc + WCOL * tr.colcost(r) if tr.exitflag: c *= EXITDISCOUNT lst.append((r, c)) candsper.append(lst) for r, c in lst: if r not in candidx: candidx[r] = len(candall) candall.append(r) tr.lastprocfid = fid nassignedlast = 0 if candall: ta0 = time.perfcounter() M = np.full((len(tids), len(candall)), 1e6, np.float64) for ti, lst in enumerate(candsper): for r, c in lst: M[ti, candidx[r]] = c ri, ci = linearsumassignment(M) ta = time.perfcounter() - ta0 if ta > HUNGSLOWS: print(f"⚠️ Венгр f{fid}: {ta:.2f}s, матрица {len(tids)}x{len(candall)}") assigned = {} for a, b in zip(ri, ci): if M[a, b] <= CMAX: assigned[a] = candall[b] nassignedlast = len(assigned) else: assigned = {} for ti, tid in enumerate(tids): tr = tracks[tid] if ti in assigned: r = assigned[ti] tr.kf.update(float(APPX[r]), float(APPY[r])) e = EALL[r] if np.isfinite(e).all(): tr.proto = ((1 - EMACOLALPHA) * tr.proto

  • EMACOLALPHA e).astype(np.float32) g = int(AP_GIDX[r]) if g in VL_POS: ve = VL_EMB[VL_POS[g]] if tr.vl_proto is None: tr.vl_proto = (ve / max(1e-6, float(np.linalg.norm(ve)))).astype(np.float32) else: tr.vl_proto = ((1 - EMA_VL_ALPHA) tr.vl_proto
  • EMAVLALPHA ve) tr.vl_proto = (tr.vl_proto / max(1e-6, float(np.linalg.norm(tr.vl_proto)))).astype(np.float32) t = int(AP_TEAM[r]) if t in (0, 1): tr.votes[t] += 1 tr._push(fid, r) if tr.lost_streak > 0: tr.n_rec += 1 tr.lost_log.append({'from': int(tr._loss_opened), 'to': int(fid), 'dur': int(tr.lost_streak), 'R': round(tr._R, 2)}) tr.lost_streak = 0; tr.v_eff = None; tr.exit_flag = False else: if tr.lost_streak == 0: tr._loss_opened = fid tr.v_eff = float(math.hypot(tr.kf.vel)) tr.exitflag = bool(boxoutsidefrac(tr.lastbox) >= EXITAREAFRAC and projoutsidexy(tr.lastpx, tr.lastpy)) tr.loststreak += 1 tr.totallost += 1

# --- (в) почки отложенных якорей: детекции, свободные ПОСЛЕ трекинга --- freeafter = [int(r) for r in rowsframe if int(r) not in ownerbwd and APPROJ[r] and np.isfinite(APPX[r]) and np.isfinite(APPY[r])] for r in sorted(freeafter, key=lambda q: -float(APCONF[q])): best, bd = None, BUDLINKR for b in buds: if b['lastfid'] <= fid: continue if b['lastfid'] - fid > LINKGAP: continue d = math.hypot(float(APPX[r]) - b['px'], float(APPY[r]) - b['py']) if d < bd: bd, best = d, b if best is not None: best['rows'].append(r); best['lastfid'] = fid best['px'] = float(APPX[r]); best['py'] = float(APPY[r]) else: buds.append({'rows': [r], 'fidhi': fid, 'lastfid': fid, 'px': float(APPX[r]), 'py': float(APPY[r])})

# --- (г) F1: инициализация fwd-якорей ПОСЛЕ трекинга кадра --- for t in initat.get(fid, []): fr = sorted(t['frames'], key=lambda f: f['fid']) tail = [APROW[int(f['gidx'])] for f in fr[-SELWINDOW:] if int(f['gidx']) in APROW] if not tail: continue tr = BwdTrack(int(t['tid']), t.get('team'), tail, np.asarray(t['protoe'], np.float32), (np.asarray(t['vlproto'], np.float32) if t.get('vlproto') else None), deferred=False, initfid=int(fr[-1]['fid'])) tracks[tr.tid] = tr

dtf = time.perfcounter() - tf0 if dtf > FRAMESLOWS: print(f"⚠️ Медленный кадр f{fid}: {dtf:.1f}s | треков {len(tracks)} | " f"своб.дет {nfreetr} | почек {len(buds)}") if fid % 50 == 0: print(f" [f{fid}] {time.perfcounter()-tloop:6.1f}s | треков {len(tracks):2d} | " f"закреплено {len(ownerbwd)} | почек {len(buds):2d} | " f"ассоц. {nassignedlast:2d} | ОЗУ {ram_gb():.1f} ГБ")

print(f"▶️ Обратный проход завершён за {time.perfcounter()-t_loop:.1f}s")

=====================================================

5. Инварианты

=====================================================

assert len(tracks) <= KTOTALFIELD, "❌ I2: треков больше KA+KB" rowsowned = np.asarray(sorted(ownerbwd.keys()), np.int64) assert FIELDPOOL[rowsowned].all(), "❌ I3: закреплена детекция вне пула" for tr in tracks.values(): ff = [f['fid'] for f in tr.frames] assert len(ff) == len(set(ff)), f"❌ I4: две ассоциации в одном кадре (tid={tr.tid})" print(f"✅ Инварианты: OK (треков {len(tracks)} ≤ {KTOTALFIELD}, " f"из них отложенных {sum(1 for t in tracks.values() if t.deferred)})")

=====================================================

6. Согласованность проходов (ТЗ 12/15.3)

=====================================================

fwdmap = {} for t in FIELDTRACKSFWD: for f in t['frames']: fwdmap[(int(t['tid']), int(f['fid']))] = int(f['gidx']) bwdmap = {} for tr in tracks.values(): if tr.deferred: continue for f in tr.frames: bwdmap[(tr.tid, int(f['fid']))] = int(f['gidx']) keys = set(fwdmap) | set(bwdmap) agree = sum(1 for k in keys if k in fwdmap and k in bwdmap and fwdmap[k] == bwdmap[k]) conflict = sum(1 for k in keys if k in fwdmap and k in bwdmap and fwdmap[k] != bwdmap[k]) onlyfwd = sum(1 for k in keys if k in fwdmap and k not in bwdmap) onlybwd = sum(1 for k in keys if k not in fwdmap and k in bwdmap) consist = agree / max(1, len(keys))

print(f"\n🤝 Согласованность проходов: {100consist:.1f}% (цель ≥95%) " f"{'✅' if consist >= 0.95 else '⚠️'}") print(f" union {len(keys)} | согласовано {agree} | конфликтных {conflict} " f"| только fwd {only_fwd} | только bwd {only_bwd}") per_consist = [] for t in FIELD_TRACKS_FWD: tid = int(t['tid']) ks = [k for k in keys if k[0] == tid] a = sum(1 for k in ks if k in fwd_map and k in bwd_map and fwd_map[k] == bwd_map[k]) c = sum(1 for k in ks if k in fwd_map and k in bwd_map and fwd_map[k] != bwd_map[k]) of = sum(1 for k in ks if k in fwd_map and k not in bwd_map) ob = sum(1 for k in ks if k not in fwd_map and k in bwd_map) per_consist.append({'tid': tid, 'union': len(ks), 'agree': a, 'conflict': c, 'only_fwd': of, 'only_bwd': ob, 'agree_pct': round(100 a / max(1, len(ks)), 1)}) worst = sorted(perconsist, key=lambda d: d['agreepct'])[:6] print(" Худшие по согласованности:") for d in worst: print(f" tid={d['tid']:2d}: agree {d['agreepct']:5.1f}% " f"(согласн. {d['agree']}, конфликтн. {d['conflict']}, " f"только fwd {d['onlyfwd']}, только bwd {d['only_bwd']})")

=====================================================

7. Диагностика + экспорт

=====================================================

print(f"\n📋 Обратные треки:") BWDTRACKS = [] for tr in sorted(tracks.values(), key=lambda t: (t.deferred, -len(t.frames))): ff = [f['fid'] for f in tr.frames] span = (ff[-1] - ff[0] + 1) if ff else 0 cov = (len(ff) / span) if span else 0.0 print(f" tid={tr.tid:2d} team={str(tr.team):>4} " f"{'DEFERRED ' if tr.deferred else 'fwd-anchor'} " f"f{(ff[0] if ff else '—')}..{(ff[-1] if ff else '—')} n={len(ff):3d} " f"cov={cov:.2f} голоса t0:{tr.votes.get(0,0)}/t1:{tr.votes.get(1,0)} " f"потерь:{tr.totallost} восст:{tr.nrec}") BWDTRACKS.append({'tid': int(tr.tid), 'deferred': bool(tr.deferred), 'team': tr.team, 'initfid': tr.initfid, 'fidstart': (int(ff[0]) if ff else None), 'fidend': (int(ff[-1]) if ff else None), 'nframes': len(ff), 'frames': tr.frames, 'protoe': tr.proto.tolist(), 'vlproto': (tr.vlproto.tolist() if tr.vlproto is not None else None), 'votes': {'0': int(tr.votes.get(0, 0)), '1': int(tr.votes.get(1, 0))}, 'lostlog': tr.lostlog, 'stats': {'span': int(span), 'coverage': round(cov, 3), 'nlost': int(tr.totallost), 'nrecovered': int(tr.nrec)}}) BWDGIDXOWNER = {int(f['gidx']): int(t['tid']) for t in BWDTRACKS for f in t['frames']}

ndefcreated = sum(1 for d in deferredlog if d.get('action') == 'created') print(f"\n🌱 Отложенные якоря: создано {ndefcreated} | отказы: {dict(deferredrej)}") for d in deferredlog[:8]: if d.get('action') == 'created': print(f" ✅ tid={d['tid']} team={d['team']} f{d['fidlo']}..{d['fidhi']} " f"nf={d['nf']} medh={d.get('medh')}") else: print(f" ⛔ {d['reason']}: f{d['fidhi']}.. nf={d['nf']}") print(f"🚫 Командный фильтр: заблокировано пар кандидат-трек (чужая команда): " f"{team_blocks}")

payload = {'meta': {'cell': CELLTAG, 'src': SRC, 'fps': FPS, 'params': {'TMATCHCOL': TMATCHCOL, 'TVLMATCH': TVLMATCH, 'VMAXFIELD': VMAXFIELD, 'VMAXKF': VMAXKF, 'RBASE': RBASE, 'KEXPAND': KEXPAND, 'RCAP': RCAP, 'CMAX': CMAX, 'WPOS': WPOS, 'WDIR': WDIR, 'WCOL': WCOL, 'SIZELOGMAX': SIZELOGMAX, 'WGROUPBASE': WGROUPBASE, 'WGROUPSMALL': WGROUPSMALL, 'DAMPLOST': DAMPLOST, 'EMACOLALPHA': EMACOLALPHA, 'EMAVLALPHA': EMAVLALPHA, 'EXITDISCOUNT': EXITDISCOUNT, 'DEFERWINDOW': DEFERWINDOW, 'DEFERMINFIDS': DEFERMINFIDS}, 'kfield': {str(k): int(v) for k, v in KFIELD.items()}, 'ktotal': int(KTOTALFIELD), 'ntracks': len(BWDTRACKS), 'ndeferred': ndefcreated, 'deferredrejects': dict(deferredrej), 'teamfilterblocks': int(teamblocks), 'consistency': {'pct': round(100 * consist, 2), 'union': len(keys), 'agree': agree, 'conflict': conflict, 'onlyfwd': onlyfwd, 'onlybwd': onlybwd}, 'pertrackconsistency': perconsist}, 'tracks': BWDTRACKS, 'deferredlog': deferredlog} with open(BWDPATH, 'w', encoding='utf-8') as f: json.dump(payload, f, ensureascii=False, separators=(',', ':')) print(f"\n💾 {BWDPATH}") print(f" Глобали: BWDTRACKS ({len(BWDTRACKS)}), BWDGIDXOWNER ({len(BWDGIDXOWNER)}), " f"FIELDTRACKSFWD ({len(FIELDTRACKSFWD)}) | ОЗУ {ram_gb():.2f} ГБ")

gc.collect() print(f"\n✅ {CELLTAG} готов ({time.perfcounter() - t00:.1f} c). " "Следующий шаг — 31F (слияние, арбитраж, changepoint, refinement, IDs, экспорт).")

@title 31F v5.5. v5.4 + PRE-ARB TEAM FIX (коррекция команды ДО гейта арбитража)

+ старт-восстановление + карта стартов

#

v5.5 = v5.4 + три изменения (корни: поздние старты id10/18/19, cov 0.51 у id10):

[E1] PRE-ARB TEAM FIX (главный): коррекция команды по fwd-голосам (>=90% за X,

ядро-голоса монолитны за Y!=X) выполняется ДО арбитража — гейт использует

исправленную команду. В v5.4 перенос был ПОСЛЕ арбитража: гейт успел

заблокировать собственные t1-детекции перенесённого трека (94% его fwd) —

отсюда старт f10, cov 0.51, голоса t0:334/t1:0 у id10.

[E2] СТАРТ-ВОССТАНОВЛЕНИЕ: для треков с fid_start>3 — возврат их ранних

fwd-детекций (окно 40 кадров до старта) ИЗ ПУЛА: позиция <= HOLESEARCHM

от обратной экстраполяции (скорость от первой детекции) — чужой сегмент

(переключатель) НЕ прицепляется; гейт команды + цвет обязательны.

Отбора у других треков НЕТ (безопасно).

[E3] КАРТА СТАРТОВ (диагностика): для каждого трека с fid_start>3 — где его

ранние fwd-детекции (у меня / у другого tid / в пуле) + медианная дистанция

до линии трека (свой/чужой сегмент).

Удалён финализационный D2''-перенос (покрыт E1); D3 и mixed-назначение — без

изменений. СТОП-КРАН до экспорта — без изменений.

import os, gc, json, time, math import numpy as np from collections import Counter, defaultdict from scipy.optimize import linearsumassignment

t00 = time.perfcounter() CELLTAG = '31F v5.5'

=====================================================

0. ОЗУ

=====================================================

def ramgb(): try: with open('/proc/meminfo') as f: for line in f: if line.startswith('MemAvailable:'): return int(line.split()[1]) / 1e6 except Exception: return float('nan') return float('nan')

try: import matplotlib.pyplot as plt plt.close('all') except Exception: pass try: import torch as torch if torch.cuda.isavailable(): torch.cuda.emptycache() except Exception: pass gc.collect() print(f"🧹 Очистка | ОЗУ доступно: {ram_gb():.2f} ГБ")

================== КОНСТАНТЫ ==================

TMATCHCOL = float(globals().get('TMATCHCOL', 1.5)) TVLMATCH = float(globals().get('TVLMATCH', 0.30)) VMAXKF = float(globals().get('VMAXKF', 9.0)) FIELDPLAYERSPERTEAM = 10 FWDSTRONGMAJ = 0.90 # [E1]: сильная fwd-мажоритарность для pre-arb fix XTEAMSEPM = 5.0

ARBPOSGATEM, ARBHARDM, ARBHARDCOL, CMAXARB = 6.0, 12.0, 3.0, 1.2 WARBPOS, WARBCOL, WARBVL = 0.5, 0.3, 0.2 SUPWIN = 25 MINCORERTS = 50 CPMINPLATE = 30 CPLOCWIN = 10 CPSTABRATIO = 0.5 RESTITCHHOLEFRAC = 0.5 POPWIN = 20 POSGATEM = 6.0 KCOLADAPT, COLGATECAP = 2.5, 3.0 HOLESEARCHM = 4.0 HOLEBOOST = 1.5 EXTMAXGAP = 40 STARTRECWIN = 40 # [E2]: окно старт-восстановления до fidstart ATTACHHARDM, ATTACHHARDCOL = 12.0, 3.0 MINTRACKDETS = 5 VOTEMIN, VOTEMAJ = 3, 0.60 RTSQ, RTSR = 6.0, 0.20 EXTRAPCAPM = 15.0 NEAR_M = 6.0

CLSGK, CLSPLAYER, CLSREF = (int(globals().get('CLSGK', 1)), int(globals().get('CLSPLAYER', 2)), int(globals().get('CLSREF', 3))) CACHEDIR = str(globals().get('CACHEDIR', '/content/cache')) OUTPUTDIR = str(globals().get('OUTPUTDIR', '/content/output')) FWDPATH = os.path.join(OUTPUTDIR, 'fieldtracksfwd.json') BWDPATH = os.path.join(OUTPUTDIR, 'fieldtracksbwd.json') GKFINPATH = os.path.join(OUTPUTDIR, 'gktracksfinal.json') FINPATH = os.path.join(OUTPUTDIR, 'fieldtracksfinal.json') FTA2PATH = os.path.join(OUTPUTDIR, 'frameteamassignment_v2.json')

=====================================================

1. Данные

=====================================================

if ('FIELDTRACKSFWD' in globals() and globals()['FIELDTRACKSFWD'] and 'BWDTRACKS' in globals() and globals()['BWDTRACKS'] and 'APGIDX' in globals() and globals()['APGIDX'] is not None): SRC = 'memory30F' APGIDX = globals()['APGIDX']; APFID = globals()['APFID'] APCLS = globals()['APCLS']; APCONF = globals()['APCONF'] APX1 = globals()['APX1']; APY1 = globals()['APY1'] APX2 = globals()['APX2']; APY2 = globals()['APY2'] APPX = globals()['APPX']; APPY = globals()['APPY']; APPROJ = globals()['APPROJ'] APTEAM = globals()['APTEAM'] EALL = np.asarray(globals()['EALL'], np.float32) VLGIDX = globals().get('VLGIDX'); VLEMB = globals().get('VLEMB') FIELDTRACKSFWD = globals()['FIELDTRACKSFWD'] BWDTRACKS = globals()['BWDTRACKS'] else: SRC = 'cache' ap = os.path.join(CACHEDIR, 'appearancecache.npz') ftap = os.path.join(OUTPUTDIR, 'frameteamassignment.json') protop = os.path.join(OUTPUTDIR, 'teamprototypes.json') for p in (ap, ftap, protop, GKFINPATH, FWDPATH, BWDPATH): assert os.path.exists(p), f"❌ {p} не найден — выполните 28F, 29F v2.2, 30F, GK-блок." with np.load(ap) as z: APGIDX = z['gidx'].astype(np.int64); APFID = z['fid'].astype(np.int32) APCLS = z['cls'].astype(np.int8); APCONF = z['conf'].astype(np.float32) APX1 = z['x1'].astype(np.float32); APY1 = z['y1'].astype(np.float32) APX2 = z['x2'].astype(np.float32); APY2 = z['y2'].astype(np.float32) APPX = z['pitchx'].astype(np.float32); APPY = z['pitchy'].astype(np.float32) APPROJ = z['proj'].astype(bool) APCOLOR12 = z['color12'].astype(np.float32) APTEAM = z['team'].astype(np.int8) VLGIDX = (z['vlgidx'].astype(np.int64) if 'vlgidx' in z.files else np.zeros(0, np.int64)) VLEMB = (z['vlemb'].astype(np.float32) if 'vlemb' in z.files else np.zeros((0, 0), np.float32)) if SRC == 'cache': with open(ftap, encoding='utf-8') as f: fta = json.load(f) teamdisk = {} for recs in fta.get('frames', {}).values(): for r in recs: teamdisk[int(r['gidx'])] = int(r['team']) APTEAM = np.full(len(APGIDX), -1, np.int8) for i, g in enumerate(APGIDX.tolist()): t = teamdisk.get(int(g)) if t is not None: APTEAM[i] = t with open(protop, encoding='utf-8') as f: PROTO = json.load(f) BLOCKS = PROTO.get('blocks', []) CENTS = np.asarray(PROTO['centroidsscaled'], np.float32) def embed(c12): if not BLOCKS: return np.asarray(c12, np.float32) vs = [] for b in BLOCKS: s0, s1 = int(b['slice'][0]), int(b['slice'][1]) keep = np.asarray(b['keepdims'], int) part = c12[:, s0:s1][:, keep] vs.append(((part - np.asarray(b['mean'], np.float32)) / np.maximum(np.asarray(b['scale'], np.float32), 1e-6) ).astype(np.float32) / np.sqrt(max(1, len(keep)))) return np.hstack(vs) if len(vs) > 1 else vs[0] EALL = embed(APCOLOR12) with open(GKFINPATH, encoding='utf-8') as f: GKFIN = json.load(f) GKTRACKSFIN = GKFIN['tracks'] gkexcl = {int(fr['gidx']) for t in GKTRACKSFIN for fr in t['frames'] if fr.get('gidx') is not None} gkcut = {int(c['gidx']) for c in GKFIN.get('cutlog', [])} gkxarr = (np.isin(APGIDX, np.asarray(sorted(gkexcl)), np.int64) if gkexcl else np.zeros(len(APGIDX), bool)) cutxarr = (np.isin(APGIDX, np.asarray(sorted(gkcut)), np.int64) if gkcut else np.zeros(len(APGIDX), bool)) FIELDPOOL = np.isin(APCLS, [CLSPLAYER, CLSGK]) & (~gkxarr) & (~cutxarr) \ & (APCLS != CLSREF) o = np.argsort(APFID, kind='stable'); f = APFID[o] u, s = np.unique(f, returnindex=True); e = np.append(s[1:], len(f)) APFR = {int(u): np.sort(o[s:e]) for u, s, e in zip(u, s, e)} FIELDFRAMEROWS = {int(f): APFR[int(f)][FIELDPOOL[APFR[int(f)]]] for f in APFR} KTOTALFIELD = 20 if SRC == 'cache': with open(FWDPATH, encoding='utf-8') as f: FIELDTRACKSFWD = json.load(f)['tracks'] with open(BWDPATH, encoding='utf-8') as f: BWDTRACKS = json.load(f)['tracks']

NAP = len(APGIDX) APROW = {int(g): i for i, g in enumerate(APGIDX.tolist())} if VLGIDX is None: VLGIDX = np.zeros(0, np.int64); VLEMB = np.zeros((0, 0), np.float32) VLPOS = {int(g): i for i, g in enumerate(VLGIDX.tolist())} FIDS = sorted(FIELDFRAMEROWS.keys()) FPS = float(globals().get('VIDEOFPS', 25.0))

SIDETEAM = {} for t in GKTRACKSFIN: rows = [APROW[int(fr['gidx'])] for fr in t['frames'] if fr.get('gidx') is not None and int(fr['gidx']) in APROW] Ev = EALL[rows]; Ev = Ev[np.isfinite(Ev).all(1)] if len(Ev) >= 3: d0 = float(np.linalg.norm(Ev.mean(0) - CENTS[0])) d1 = float(np.linalg.norm(Ev.mean(0) - CENTS[1])) SIDETEAM[t['side']] = 0 if d0 <= d1 else 1 if len(SIDETEAM) == 2 and SIDETEAM.get('L') == SIDETEAM.get('R'): medx = {tm: float(np.nanmedian(APPX[(APTEAM == tm)])) for tm in (0, 1)} SIDETEAM['L'] = 0 if medx[0] <= medx[1] else 1 SIDETEAM['R'] = 1 - SIDETEAM['L'] TL, TR = SIDETEAM.get('L'), SIDETEAM.get('R') TEAM12OFFTA = {TL: 1, TR: 2} print(f"⚙️ {CELLTAG}: источник {SRC} | fwd {len(FIELDTRACKSFWD)} / bwd {len(BWDTRACKS)} | " f"приор 10/10 | T(L)=t{TL} -> команда 1 | T(R)=t{TR} -> команда 2 | " f"пул из GK-final (excl {len(gkexcl)}, cut {len(gkcut)}) | кадров {len(FIDS)} | " f"ОЗУ {ramgb():.2f} ГБ")

=====================================================

2. Утилиты

=====================================================

def rtssmooth(detbyfid, flo, fhi): fids = list(range(int(flo), int(fhi) + 1)) n = len(fids) if n == 0 or not detbyfid: return {} dt = 1.0 / FPS F = np.array([[1, 0, dt, 0], [0, 1, 0, dt], [0, 0, 1, 0], [0, 0, 0, 1]], np.float64) Q = RTSQ np.array([[dt4/4, 0, dt3/2, 0], [0, dt4/4, 0, dt3/2], [dt3/2, 0, dt2, 0], [0, dt3/2, 0, dt2]], np.float64) H = np.array([[1., 0, 0, 0], [0, 1., 0, 0]], np.float64) R = np.eye(2) RTSR I4 = np.eye(4) ffirst = min(detbyfid) x = np.array([detbyfid[ffirst][0], detbyfid[ffirst][1], 0.0, 0.0], np.float64) P = np.diag([0.5, 0.5, 9.0, 9.0]) if ffirst == fids[0] else np.diag([25.0, 25.0, 9.0, 9.0]) xf = [None] * n; Pf = [None] * n; xp = [None] * n; Pp = [None] * n for i, f in enumerate(fids): if i > 0: x = F @ x P = F @ P @ F.T + Q xp[i] = x.copy(); Pp[i] = P.copy() if f in detbyfid: z = np.array(detbyfid[f], np.float64) S = H @ P @ H.T + R K = P @ H.T @ np.linalg.inv(S) x = x + K @ (z - H @ x) IKH = I4 - K @ H P = IKH @ P @ IKH.T + K @ R @ K.T xf[i] = x.copy(); Pf[i] = P.copy() xs = [None] * n xs[n - 1] = xf[n - 1].copy() for i in range(n - 2, -1, -1): C = Pf[i] @ F.T @ np.linalg.inv(Pp[i + 1]) xs[i] = xf[i] + C @ (xs[i + 1] - xp[i + 1]) out = {} for i, f in enumerate(fids): s = xs[i] vx, vy = float(s[2]), float(s[3]) v = math.hypot(vx, vy) if v > VMAXKF: vx = VMAX_KF / v; vy = VMAX_KF / v out[f] = (float(s[0]), float(s[1]), vx, vy) return out

def makesupport(rtsd): if not rtsd: return None ks = sorted(rtsd) def sup(fid): fid = int(fid) if fid in rtsd: return rtsd[fid][0], rtsd[fid][1] if fid < ks[0]: sx, sy, vx, vy = rtsd[ks[0]] dfr = (ks[0] - fid) / FPS else: sx, sy, vx, vy = rtsd[ks[-1]] dfr = (fid - ks[-1]) / FPS dx, dy = vx * dfr, vy * dfr nrm = math.hypot(dx, dy) if nrm > EXTRAPCAPM: dx *= EXTRAPCAPM / nrm; dy *= EXTRAPCAP_M / nrm return sx + dx, sy + dy return sup

def medmadgate(vals): v = np.asarray([x for x in vals if x is not None and np.isfinite(x)], np.float64) if len(v) == 0: return TMATCHCOL med = float(np.median(v)) mad = float(np.median(np.abs(v - med))) 1.4826 return float(np.clip(med + K_COL_ADAPT mad, TMATCHCOL, COLGATECAP))

def meanE(rows): Ev = EALL[rows] Ev = Ev[np.isfinite(Ev).all(1)] return (Ev.mean(axis=0).astype(np.float32) if len(Ev) else None)

def votesof(rows): return Counter(int(APTEAM[r]) for r in rows if int(AP_TEAM[r]) in (0, 1))

def votesteam(tid): rows = [APROW[r['gidx']] for r in recs[tid].values() if r['gidx'] in APROW] if not rows: return None c = votesof(rows) tot = c.get(0, 0) + c.get(1, 0) if tot >= VOTEMIN and max(c.get(0, 0), c.get(1, 0)) / tot >= VOTEMAJ: return 0 if c.get(0, 0) >= c.get(1, 0) else 1 return None

def dgkof(row, proto): e = EALL[row] if proto is None or not np.isfinite(e).all(): return None return float(np.linalg.norm(e - proto))

=====================================================

3. Предложения проходов + ядра

=====================================================

fwdbytid, fwdmeta = {}, {} for t in FIELDTRACKSFWD: tid = int(t['tid']) fwdbytid[tid] = {int(f['fid']): int(f['gidx']) for f in t['frames']} fwdmeta[tid] = t bwdbytid, bwdmeta = {}, {} for t in BWDTRACKS: if t.get('deferred'): continue tid = int(t['tid']) bwdbytid[tid] = {int(f['fid']): int(f['gidx']) for f in t['frames']} bwdmeta[tid] = t ALLTIDS = sorted(set(fwdbytid) | set(bwdbytid))

fwdvotes = {} for tid in ALLTIDS: rows = [APROW[g] for g in fwdbytid.get(tid, {}).values() if g in APROW] fwdvotes[tid] = votesof(rows)

props = defaultdict(list) corepairs = set() for tid in ALLTIDS: fm, bm = fwdbytid.get(tid, {}), bwdbytid.get(tid, {}) for fid, g in fm.items(): if bm.get(fid) == g: corepairs.add((tid, fid)) else: props[fid].append((tid, g, 'fwd')) for fid, g in bm.items(): if fid not in fm or fm[fid] != g: props[fid].append((tid, g, 'bwd')) print(f"🧩 Слияние: ядер {len(corepairs)} | предложений вне ядра " f"{sum(len(v) for v in props.values())}")

recs = {tid: {} for tid in ALLTIDS} ownerg = {} corebytid = defaultdict(dict) for (tid, fid) in corepairs: g = fwdbytid[tid][fid] recs[tid][fid] = {'gidx': g, 'src': 'core', 'pass': 'both'} ownerg[g] = tid corebytid[tid][fid] = g

poolrows = set() pool = defaultdict(set) def pooladd(row, fid): if int(row) not in poolrows: poolrows.add(int(row)) pool[int(fid)].add(int(row)) def pooltake(row): poolrows.discard(int(row)) pool[int(AP_FID[int(row)])].discard(int(row))

=====================================================

3b. [E1] PRE-ARB TEAM FIX: коррекция команды ДО гейта арбитража

=====================================================

TEAMFIX = {} prearblog = [] for tid in ALLTIDS: corerows = [APROW[g] for g in corebytid[tid].values() if g in APROW] if len(corerows) < 10: continue c = votesof(corerows) tot = c.get(0, 0) + c.get(1, 0) if tot < VOTEMIN: continue mteam = 0 if c.get(0, 0) >= c.get(1, 0) else 1 if max(c.get(0, 0), c.get(1, 0)) / tot < VOTEMAJ: continue # ядро не монолитно — гейт и так открыт fv = fwdvotes.get(tid, Counter()) f0v, f1v = fv.get(0, 0), fv.get(1, 0) ftot = f0v + f1v if ftot < VOTEMIN: continue fteam = 0 if f0v >= f1v else 1 fshare = max(f0v, f1v) / ftot if fteam != mteam and fshare >= FWDSTRONGMAJ: TEAMFIX[tid] = fteam prearblog.append({'tid': tid, 'from': mteam, 'to': fteam, 'corevotes': {'t0': c.get(0, 0), 't1': c.get(1, 0)}, 'fwdvotes': {'t0': f0v, 't1': f1v}, 'fwdshare': round(fshare, 3)}) print(f" 🔧 E1 PRE-ARB: tid={tid}: ядро-голоса t{mteam} (искажены bwd-" f"фильтром), fwd t0:{f0v}/t1:{f1v} = {100*fshare:.0f}% за t{fteam} " f"-> гейт арбитража будет использовать t{fteam}") if not prearblog: print(" ℹ️ E1 PRE-ARB: коррекций нет") origvotesteam = votesteam def votesteam(tid): # noqa: F811 — с коррекцией, ДО арбитража if tid in TEAMFIX: return TEAMFIX[tid] return origvotes_team(tid)

=====================================================

4. Прототипы/VL + локальные опоры

=====================================================

protosarb, vlsarb = {}, {} proppos = {} for tid in ALLTIDS: protosarb[tid] = (np.asarray(fwdmeta[tid]['protoe'], np.float32) if tid in fwdmeta and fwdmeta[tid].get('protoe') is not None else meanE([APROW[g] for g in fwdbytid.get(tid, {}).values()])) vp = None for src in (fwdmeta.get(tid), bwdmeta.get(tid)): if src is not None and src.get('vlproto') is not None: vp = np.asarray(src['vlproto'], np.float32) break vlsarb[tid] = vp pts = {} for src in (fwdbytid.get(tid, {}), bwdbytid.get(tid, {})): for fid, g in src.items(): row = APROW[g] if APPROJ[row] and np.isfinite(APPX[row]) and np.isfinite(APPY[row]): pts.setdefault(int(fid), []).append((float(APPX[row]), float(APPY[row]))) proppos[tid] = pts

def nearestpropdist(tid, fid, x, y): pts = proppos.get(tid) if not pts: return None best = None for f2 in range(int(fid) - SUPWIN, int(fid) + SUPWIN + 1): for (px, py) in pts.get(f2, ()): d = math.hypot(px - x, py_ - y) if best is None or d < best: best = d return best

=====================================================

5. Арбитраж (гейт — с E1-коррекцией)

=====================================================

narbwin, narblost, nteamblock = 0, 0, 0 for fid in sorted(props.keys()): entries = [(tid, g, p) for (tid, g, p) in props[fid] if (tid, fid) not in corepairs] if not entries: continue ownedhere = {fwdbytid[tid][fid] for (tid, fid2) in corepairs if fid2 == fid} cands = sorted({g for (, g, ) in entries} - ownedhere) cands = [g for g in cands if g in APROW and bool(FIELDPOOL[APROW[g]])] if not cands: continue tids = sorted({tid for (tid, , ) in entries}) passof = {(tid, g): p for (tid, g, p) in entries} M = np.full((len(tids), len(cands) + 1), 1e6, np.float64) M[:, len(cands)] = CMAXARB 0.999 for ti, tid in enumerate(tids): proto = protos_arb[tid] vl = vls_arb[tid] t_trk = votes_team(tid) for ci, g in enumerate(cands): row = AP_ROW[g] x_, y_ = float(AP_PX[row]), float(AP_PY[row]) d = nearest_prop_dist(tid, fid, x_, y_) if d is None or d > ARB_HARD_M: continue lab = int(AP_TEAM[row]) if lab in (0, 1) and t_trk is not None and t_trk != lab: n_team_block += 1 continue dgk = dgk_of(row, proto) if dgk is None or dgk > ARB_HARD_COL: continue vlterm = 0.5 if vl is not None and g in VL_POS: ve = VL_EMB[VL_POS[g]] vlterm = (1.0 - float(np.dot(ve, vl)) / max(1e-6, float(np.linalg.norm(ve)))) / 2.0 cost = (W_ARB_POS (d / ARBPOSGATE_M)

  • WARBCOL * (dgk / TMATCHCOL)
  • WARBVL * (vlterm / TVLMATCH)) if cost <= CMAXARB: M[ti, ci] = cost ri, ci = linearsumassignment(M) won = set() for a, b in zip(ri, ci): if b < len(cands) and M[a, b] < 1e6: tid, g = tids[a], cands[b] recs[tid][fid] = {'gidx': g, 'src': 'arb', 'pass': passof.get((tid, g), '?')} ownerg[g] = tid won.add(g) narbwin += 1 for g in cands: if g not in won: row = APROW[g] if bool(FIELDPOOL[row]): pooladd(row, fid) narblost += 1 print(f"⚖️ Арбитраж (гейт с E1-коррекцией): назначено {narbwin} | в пул {narblost} | " f"блокировано чужой командой {nteam_block}")

for tid in ALLTIDS: if 0 < len(recs[tid]) < MINTRACKDETS: for f, r in recs[tid].items(): del ownerg[r['gidx']] pooladd(APROW[r['gidx']], f) recs[tid] = {}

=====================================================

6. Цветовой changepoint

=====================================================

def detectcolorcut(fidrows): dets = [(f, EALL[r]) for f, r in sorted(fidrows.items()) if np.isfinite(EALL[r]).all()] n = len(dets) if n < 2 CP_MIN_PLATE + 1: return None Es = np.stack([e for _, e in dets]) fids = [f for f, _ in dets] csum = np.cumsum(Es, axis=0) total = csum[-1] best_k, best_d = None, -1.0 for k in range(CP_MIN_PLATE, n - CP_MIN_PLATE): mL = csum[k - 1] / k mR = (total - csum[k - 1]) / (n - k) d = float(np.linalg.norm(mL - mR)) if d > best_d: best_k, best_d = k, d if best_k is None or best_d <= T_MATCH_COL: return None k = best_k mL = csum[k - 1] / k mR = (total - csum[k - 1]) / (n - k) intraL = float(np.median(np.linalg.norm(Es[:k] - mL, axis=1))) intraR = float(np.median(np.linalg.norm(Es[k:] - mR, axis=1))) if max(intraL, intraR) >= CP_STAB_RATIO bestd: return None lo = max(0, k - CPLOCWIN); hi = min(n, k + CPLOCWIN) if k - lo >= 3 and hi - k >= 3: dloc = float(np.linalg.norm(Es[lo:k].mean(0) - Es[k:hi].mean(0))) if dloc <= TMATCHCOL: return None return {'fidcut': int(fids[k]), 'kind': 'color', 'dshift': round(bestd, 3)}

cutlog, cpwarn = [], [] for tid in sorted(ALLTIDS): if not recs[tid]: continue cut = detectcolorcut({f: APROW[r['gidx']] for f, r in recs[tid].items()}) if cut is None: continue fidcut = cut['fidcut'] segA = {f: r for f, r in recs[tid].items() if f < fidcut} segB = {f: r for f, r in recs[tid].items() if f >= fidcut} if len(segA) < MINTRACKDETS or len(segB) < MINTRACKDETS: cutlog.append({'tid': tid, **cut, 'action': 'skippedtooshort'}) continue rowsB = [APROW[segB[f]['gidx']] for f in segB] protoB = meanE(rowsB) cB = votesof(rowsB) totB = cB.get(0, 0) + cB.get(1, 0) teamB = (0 if cB.get(0, 0) >= cB.get(1, 0) else 1) if totB else None host, hostinfo = None, None if teamB is not None: bestscore = None for u in [x for x in recs if x != tid and len(recs[x]) >= MINTRACKDETS]: if votesteam(u) != teamB: continue holes = [f for f in segB if f not in recs[u]] if len(holes) < RESTITCHHOLEFRAC * len(segB): continue ds = [] for f in segB: d = nearestpropdist(u, f, float(APPX[APROW[segB[f]['gidx']]]), float(APPY[APROW[segB[f]['gidx']]])) if d is not None: ds.append(d) if not ds: continue dpos = float(np.median(ds)) protoU = meanE([APROW[r['gidx']] for r in recs[u].values()]) dcol = (float(np.linalg.norm(protoB - protoU)) if (protoB is not None and protoU is not None) else None) if dpos > ARBHARDM or dcol is None or dcol > ARBHARDCOL: continue score = dpos / ARBPOSGATEM + dcol / TMATCHCOL if bestscore is None or score < bestscore: bestscore = score host, hostinfo = u, {'dpos': round(dpos, 2), 'dcol': round(dcol, 3)} if host is not None: nmv = npl = 0 for f in sorted(segB): g = segB[f]['gidx'] del recs[tid][f] if f not in recs[host]: recs[host][f] = {'gidx': g, 'src': 'restitch', 'pass': 'bwd'} ownerg[g] = host nmv += 1 else: del ownerg[g] pooladd(APROW[g], f) npl += 1 cutlog.append({'tid': tid, **cut, 'action': 'cut', 'hosttid': host, 'hostinfo': hostinfo, 'nmoved': nmv, 'npool': npl}) print(f" ✂️ tid={tid}: цветовой разрез f{fidcut} (shift={cut['dshift']}) | " f"сегмент B -> хозяин tid{host} {hostinfo} | пересшито {nmv}, в пул {npl}") else: cpwarn.append({'tid': tid, **cut}) print(f" ⚠️ tid={tid}: цветовой сдвиг f{fidcut} (shift={cut['dshift']}) — " f"хозяин не найден, трек оставлен (проверить в 32F)") print(f"✂️ Changepoint: разрезов " f"{sum(1 for c in cutlog if c.get('action') == 'cut')} | " f"предупреждений {len(cpwarn)}")

for tid in sorted(ALLTIDS): if 0 < len(recs[tid]) < MINTRACKDETS: for f, r in recs[tid].items(): del ownerg[r['gidx']] pooladd(APROW[r['gidx']], f) recs[tid] = {}

=====================================================

7. Попконтроль

=====================================================

cnt = {f: {0: 0, 1: 0} for f in FIDS} for tid in [t for t in recs if recs[t]]: tft = votesteam(tid) if tft is None: continue for f in recs[tid]: cnt[f][tft] += 1 boostteams = set() for tm in (0, 1): under = [f for f in FIDS if cnt[f][tm] < FIELDPLAYERSPERTEAM] if under: boostteams.add(tm) print(f"👥 Попконтроль: недогруз-команды (радиус дыр x{HOLEBOOST}): {sorted(boostteams)}")

=====================================================

8. Refinement

=====================================================

def gateok(tid, row): lab = int(APTEAM[row]) if lab in (0, 1): t = votes_team(tid) return (t is None) or (t == lab) return True

ncutval = 0 for tid in [t for t in recs if recs[t]]: for it in range(2): rowst = [APROW[r['gidx']] for r in recs[tid].values()] proto = meanE(rowst) if proto is None: break gate = medmadgate([dgkof(r, proto) for r in rowst]) fs = sorted(recs[tid]) det = {f: (float(APPX[APROW[recs[tid][f]['gidx']]]), float(APPY[APROW[recs[tid][f]['gidx']]])) for f in fs} rtsd = rtssmooth(det, fs[0], fs[-1]) viol = [] for f in fs: row = APROW[recs[tid][f]['gidx']] pe = math.hypot(float(APPX[row]) - rtsd[f][0], float(APPY[row]) - rtsd[f][1]) dg = dgkof(row, proto) if pe > POSGATEM or dg is None or dg > gate: viol.append(f) if not viol: break for f in viol: g = recs[tid][f]['gidx'] del recs[tid][f] del ownerg[g] pooladd(APROW[g], f) ncutval += 1 print(f"🔧 Валидация: вырезано {ncutval}")

for row in np.where(FIELDPOOL & APPROJ & np.isfinite(APPX) & np.isfinite(APPY))[0]: g = int(APGIDX[int(row)]) if g not in ownerg: pooladd(int(row), int(APFID[int(row)])) print(f"📦 Пул для заполнения: {len(pool_rows)}")

def trackrts(tid): fs = sorted(recs[tid]) det = {f: (float(APPX[APROW[recs[tid][f]['gidx']]]), float(APPY[APROW[recs[tid][f]['gidx']]])) for f in fs} return rtssmooth(det, fs[0], fs[-1]), fs[0], fs[-1]

nrechole = nrecext = nrecsweep = 0 ACT = [t for t in recs if len(recs[t]) >= MINTRACKDETS] for tid in sorted(ACT, key=lambda t: -len(recs[t])): rtsd, flo, fhi = trackrts(tid) if not rtsd: continue rowst = [APROW[r['gidx']] for r in recs[tid].values()] proto = meanE(rowst) gate = medmadgate([dgkof(r, proto) for r in rowst]) tft = votesteam(tid) radius = HOLESEARCHM * (HOLEBOOST if tft in boostteams else 1.0) for f in range(flo, fhi + 1): if f in recs[tid] or f not in pool or not pool[f]: continue sx, sy = rtsd[f][0], rtsd[f][1] best, bestc = None, None for row in sorted(pool[f]): if not gateok(tid, row): continue d = math.hypot(float(APPX[row]) - sx, float(APPY[row]) - sy) if d > radius: continue dg = dgkof(row, proto) if dg is None or dg > min(gate, ATTACHHARDCOL): continue c = d / radius + dg / max(gate, 1e-6) if bestc is None or c < bestc: bestc, best = c, row if best is not None: recs[tid][f] = {'gidx': int(APGIDX[best]), 'src': 'recovered', 'pass': 'hole'} ownerg[int(APGIDX[best])] = tid pooltake(best) nrechole += 1

for tid in sorted(ACT, key=lambda t: len(recs[t])): changed = True while changed: changed = False rtsd, flo, fhi = trackrts(tid) if not rtsd: break rowst = [APROW[r['gidx']] for r in recs[tid].values()] proto = meanE(rowst) gate = medmadgate([dgkof(r, proto) for r in rowst]) sup = makesupport(rtsd) for direction in (+1, -1): gap = 0 while gap < EXTMAXGAP: nf = (fhi + 1) if direction > 0 else (flo - 1) if nf < FIDS[0] or nf > FIDS[-1] or nf in recs[tid]: break sx, sy = sup(nf) best, bestc = None, None if nf in pool: for row in sorted(pool[nf]): if not gateok(tid, row): continue d = math.hypot(float(APPX[row]) - sx, float(APPY[row]) - sy) if d > HOLESEARCHM: continue dg = dgkof(row, proto) if dg is None or dg > min(gate, ATTACHHARDCOL): continue c = d / HOLESEARCHM + dg / max(gate, 1e-6) if bestc is None or c < bestc: bestc, best = c, row if best is not None: recs[tid][nf] = {'gidx': int(APGIDX[best]), 'src': 'recovered', 'pass': 'ext'} ownerg[int(APGIDX[best])] = tid pooltake(best) if direction > 0: fhi = nf else: flo = nf changed = True gap = 0 else: gap += 1 if direction > 0: fhi = nf else: flo = nf nrecext = sum(1 for tid in ACT for r in recs[tid].values() if r['pass'] == 'ext')

--- [E2] СТАРТ-ВОССТАНОВЛЕНИЕ: возврат ранних fwd-детекций из пула ---

nstartrec = 0 startreclog = [] ACT = [t for t in recs if len(recs[t]) >= MINTRACKDETS] for tid in sorted(ACT, key=lambda t: len(recs[t])): fs = sorted(recs[tid]) f0t = fs[0] if f0t <= 3: continue rtsd, , = trackrts(tid) if not rtsd or f0t not in rtsd: continue rowst = [APROW[r['gidx']] for r in recs[tid].values()] proto = meanE(rowst) gate = medmadgate([dgkof(r, proto) for r in rowst]) sx, sy, vx, vy = rtsd[f0t] early = [(f, g) for f, g in sorted(fwdbytid.get(tid, {}).items()) if f < f0t and f >= max(FIDS[0], f0t - STARTRECWIN)] ntake = 0 for f, g in sorted(early, key=lambda q: -q[0]): # от старта вниз if g not in APROW or not bool(FIELDPOOL[APROW[g]]): continue if g in ownerg or g not in poolrows: continue # занята другим / не в пуле row = APROW[g] dfr = (f0t - f) / FPS ex, ey = sx - vx dfr, sy - vy dfr d = math.hypot(float(APPX[row]) - ex, float(APPY[row]) - ey) if d > HOLESEARCHM: continue # чужой сегмент (переключатель) if not gateok(tid, row): continue dg = dgkof(row, proto) if dg is None or dg > min(gate, ATTACHHARDCOL): continue recs[tid][f] = {'gidx': g, 'src': 'startrec', 'pass': 'start'} ownerg[g] = tid pooltake(row) # обновление обратной экстраполяции по цепочке vx = (sx - float(APPX[row])) / max(dfr, 1e-3) vy = (sy - float(APPY[row])) / max(dfr, 1e-3) sx, sy = float(APPX[row]), float(APPY[row]) f0t = f ntake += 1 if ntake: nstartrec += ntake newf0 = min(recs[tid]) startreclog.append({'tid': tid, 'taken': ntake, 'newfidstart': int(newf0)}) print(f" 🚩 E2 старт: tid={tid} +{ntake} ранних дет. из пула -> " f"новый старт f{newf0} (был f{fs[0]})") print(f"🚩 E2 старт-восстановление: возвращено {nstart_rec} детекций")

rtscache = {tid: trackrts(tid) for tid in ACT} protocache = {tid: meanE([APROW[r['gidx']] for r in recs[tid].values()]) for tid in ACT} gatecache = {tid: medmadgate([dgkof(r, protocache[tid]) for r in [APROW[x['gidx']] for x in recs[tid].values()]]) for tid in ACT} for fid in sorted(pool.keys()): for row in sorted(pool[fid], key=lambda q: -float(APCONF[q])): if row not in poolrows or fid not in pool or row not in pool[fid]: continue best, bestc = None, None for tid in ACT: if fid in recs[tid] or not gateok(tid, row): continue rtsd, flo, fhi = rtscache[tid] if not rtsd or not (flo <= fid <= fhi): continue sx, sy = rtsd[fid][0], rtsd[fid][1] d = math.hypot(float(APPX[row]) - sx, float(APPY[row]) - sy) if d > ATTACHHARDM: continue dg = dgkof(row, protocache[tid]) if dg is None or dg > ATTACHHARDCOL: continue c = d / ARBPOSGATEM + dg / max(gatecache[tid], 1e-6) if c <= CMAXARB and (bestc is None or c < bestc): bestc, best = c, tid if best is not None: recs[best][fid] = {'gidx': int(APGIDX[row]), 'src': 'recovered', 'pass': 'sweep'} ownerg[int(APGIDX[row])] = best pooltake(row) rtscache[best] = trackrts(best) nrecsweep += 1 print(f"🔗 Refinement: дыры {nrechole} | края {nrecext} | старты {nstartrec} | " f"замёт {nrecsweep} | в пуле осталось {len(poolrows)}")

=====================================================

8b. [E3] КАРТА СТАРТОВ: где ранние fwd-детекции треков с поздним стартом

=====================================================

print(f"\n🧭 E3 карта стартов (треки с fidstart>3):") for tid in [t for t in recs if recs[t]]: fs = sorted(recs[tid]) f0t = fs[0] if f0t <= 3: continue rtsd, , = trackrts(tid) if not rtsd or f0t not in rtsd: continue sx, sy, vx, vy = rtsd[f0t] early = [(f, g) for f, g in sorted(fwdbytid.get(tid, {}).items()) if f < f0t and f >= max(FIDS[0], f0t - STARTRECWIN)] stat = Counter() dists = [] for f, g in early: if g not in APROW: stat['вне пула'] += 1 continue row = APROW[g] dfr = (f0t - f) / FPS ex, ey = sx - vx * dfr, sy - vy * dfr dists.append(math.hypot(float(APPX[row]) - ex, float(APPY[row]) - ey)) own = ownerg.get(g) if own == tid: stat['у меня (ранее восстановлено)'] += 1 elif own is not None: stat[f'у tid{own}'] += 1 elif g in poolrows: stat['в пуле'] += 1 else: stat['вырезана валидацией'] += 1 medd = round(float(np.median(dists)), 1) if dists else None verdict = ('СВОЙ сегмент (медиана {:.1f} м до линии трека)'.format(medd) if medd is not None and medd <= HOLESEARCHM else ('ЧУЖОЙ сегмент ({:.1f} м — переключатель, старт честный)'.format(medd) if med_d is not None else 'нет ранних fwd-детекций')) print(f" tid={tid}: старт f{f0t} | ранних fwd-дет. {len(early)} | " f"{dict(stat)} | {verdict}")

=====================================================

9. Финализация: приор 10/10 + mixed + D3 (E1 уже применён)

=====================================================

ACTIVETIDS = [t for t in recs if len(recs[t]) >= MINTRACKDETS] solidteam = {} nsolid = {0: 0, 1: 0} mixedtids = [] for tid in ACTIVETIDS: tft = votesteam(tid) if tft is not None: solidteam[tid] = tft nsolid[tft] += 1 else: mixedtids.append(tid) print(f"\n🏟 Приор 10/10 (E1 уже в гейте): твёрдых t0={nsolid[0]}, t1={nsolid[1]} | " f"смешанных {len(mixedtids)} {mixedtids}") teamassign = dict(solidteam) slots = {t: FIELDPLAYERSPERTEAM - nsolid[t] for t in (0, 1)} balancelog = [] for tid in sorted(mixedtids, key=lambda t: -len(recs[t])): rowst = [APROW[r['gidx']] for r in recs[tid].values()] c = votesof(rowst) proto = meanE(rowst) colpref = None if proto is not None: d0 = float(np.linalg.norm(proto - CENTS[0])) d1 = float(np.linalg.norm(proto - CENTS[1])) colpref = 0 if d0 <= d1 else 1 tmpl = 0 if c.get(0, 0) >= c.get(1, 0) else 1 prefs = [tmpl] + ([colpref] if (colpref is not None and colpref != tmpl) else []) \

  • [t for t in (0, 1) if t != tmpl and t != colpref] placed = None for tm in prefs: if slots[tm] > 0: placed = tm slots[tm] -= 1 break if placed is None: placed = tmpl teamassign[tid] = placed forced = (placed != tmpl) balancelog.append({'tid': tid, 'votes': dict(c), 'assigned': placed, 'colpref': colpref, 'forced': forced}) print(f" ⚖️ mixed tid={tid} (t0:{c.get(0,0)}/t1:{c.get(1,0)}, цвет t{colpref}): " f"-> t{placed}" + (" (дефицит)" if forced else "")) nassign = {t: sum(1 for tid in ACTIVETIDS if teamassign.get(tid) == t) for t in (0, 1)}

if nassign[0] > 10 or nassign[1] > 10: print(f" ⚖️ D3: переполнение t0={nassign[0]}/t1={nassign[1]} — перенос") teamx = {0: [], 1: []} for tid in ACTIVETIDS: tft = teamassign.get(tid) if tft is None: continue xs = [float(APPX[APROW[recs[tid][f]['gidx']]]) for f in recs[tid] if f <= 40 and recs[tid][f]['gidx'] in APROW] if xs: teamx[tft].append(float(np.median(xs))) medx = {t: (float(np.median(teamx[t])) if teamx[t] else None) for t in (0, 1)} for tm in (0, 1): other = 1 - tm while nassign[tm] > FIELDPLAYERSPERTEAM and nassign[other] < FIELDPLAYERSPERTEAM: cands = [tid for tid in ACTIVETIDS if teamassign.get(tid) == tm] besttid, bestev = None, None for tid in cands: rowst = [APROW[r['gidx']] for r in recs[tid].values()] proto = meanE(rowst) colteam = None if proto is not None: d0 = float(np.linalg.norm(proto - CENTS[0])) d1 = float(np.linalg.norm(proto - CENTS[1])) colteam = 0 if d0 <= d1 else 1 xs = [float(APPX[APROW[recs[tid][f]['gidx']]]) for f in recs[tid] if f <= 40 and recs[tid][f]['gidx'] in APROW] xteam = None if xs and medx[0] is not None and medx[1] is not None \ and abs(medx[0] - medx[1]) >= XTEAMSEPM: mx = float(np.median(xs)) xteam = 0 if abs(mx - medx[0]) < abs(mx - medx[1]) else 1 fv = fwdvotes.get(tid, Counter()) fteam = None ftot = fv.get(0, 0) + fv.get(1, 0) if ftot >= VOTEMIN: fteam = 0 if fv.get(0, 0) >= fv.get(1, 0) else 1 evidences = [c for c in (colteam, xteam, fteam) if c == other] if len(evidences) >= 2: if bestev is None or len(evidences) > bestev: bestev, besttid = len(evidences), tid if besttid is None: print(f" ⚠️ D3: нет трека с >=2 свидетельствами за t{other}") break teamassign[besttid] = other nassign[tm] -= 1 nassign[other] += 1 print(f" 🔁 D3: tid={besttid} перенесён t{tm} -> t{other} " f"({bestev}/3 свидетельств)") nassign = {t: sum(1 for tid in ACTIVETIDS if teamassign.get(tid) == t) for t in (0, 1)} okteams = (nassign[0] == 10 and nassign[1] == 10) print(f"👥 Составы: t0={nassign[0]}, t1={nassign[1]} " f"{'✅' if ok_teams else '⚠️ НЕ 10/10'}")

=====================================================

10. Сборка + инварианты + стоп-кран + экспорт

=====================================================

oopcut = [] for tid in ACTIVETIDS: bad = [f for f, r in recs[tid].items() if (r['gidx'] not in APROW) or (not bool(FIELDPOOL[APROW[r['gidx']]]))] for f in bad: r = recs[tid][f] del ownerg[r['gidx']] del recs[tid][f] oopcut.append({'tid': tid, 'fid': f, 'gidx': r['gidx']}) ACTIVETIDS = [t for t in recs if len(recs[t]) >= MINTRACKDETS]

FINAL = [] for tid in ACTIVETIDS: rowst = [APROW[r['gidx']] for r in recs[tid].values()] proto = meanE(rowst) teamfta = teamassign.get(tid, votesteam(tid)) votes = votesof(rowst) fv = fwdvotes.get(tid, Counter()) fs = sorted(recs[tid]) det = {f: (float(APPX[APROW[recs[tid][f]['gidx']]]), float(APPY[APROW[recs[tid][f]['gidx']]])) for f in fs} rtsd = rtssmooth(det, fs[0], fs[-1]) gate = medmadgate([dgkof(r, proto) for r in rowst]) dgs, pes = [], [] for f in fs: row = APROW[recs[tid][f]['gidx']] dg = dgkof(row, proto) if dg is not None: dgs.append(dg) pes.append(math.hypot(float(APPX[row]) - rtsd[f][0], float(APPY[row]) - rtsd[f][1])) purad = float(np.mean((np.asarray(dgs) <= gate) & (np.asarray(pes[:len(dgs)]) <= POSGATEM))) if dgs else None parts = [] for f in fs: src = recs[tid][f]['src'] if parts and parts[-1]['src'] == src and f == parts[-1]['fidend'] + 1: parts[-1]['fidend'] = f else: parts.append({'fidstart': f, 'fidend': f, 'src': src}) framesout = [] for f in range(fs[0], fs[-1] + 1): sx, sy, vx, vy = rtsd[f] r = recs[tid].get(f) if r is not None: row = APROW[r['gidx']] framesout.append({'fid': f, 'gidx': r['gidx'], 'bbox': [round(float(APX1[row]), 1), round(float(APY1[row]), 1), round(float(APX2[row]), 1), round(float(APY2[row]), 1)], 'px': round(float(APPX[row]), 2), 'py': round(float(APPY[row]), 2), 'sx': round(sx, 2), 'sy': round(sy, 2), 'vx': round(vx, 2), 'vy': round(vy, 2), 'src': r['src']}) else: framesout.append({'fid': f, 'gidx': None, 'bbox': None, 'px': None, 'py': None, 'sx': round(sx, 2), 'sy': round(sy, 2), 'vx': round(vx, 2), 'vy': round(vy, 2), 'src': 'gap'}) vlrows = [VLPOS[recs[tid][f]['gidx']] for f in fs if recs[tid][f]['gidx'] in VLPOS] vlproto = None if vlrows: v = VLEMB[vlrows].mean(axis=0) vlproto = (v / max(1e-6, float(np.linalg.norm(v)))).tolist() xst = [float(APPX[r]) for r in rowst if np.isfinite(APPX[r])] FINAL.append({'tid': tid, 'teamfta': teamfta, 'teamfixed': tid in TEAMFIX, 'votes': votes, 'fwdvotes': dict(fv), 'proto': proto, 'vlproto': vlproto, 'medx': (float(np.median(xst)) if xst else None), 'fidstart': fs[0], 'fidend': fs[-1], 'ndets': len(fs), 'frames': framesout, 'parts': parts, 'gate': gate, 'puradaptive': purad, 'changepoints': [c for c in cutlog if c.get('action') == 'cut' and (c['tid'] == tid or c.get('hosttid') == tid)]})

t1 = [t for t in FINAL if t['teamfta'] == TL] t2 = [t for t in FINAL if t['teamfta'] == TR] tX = [t for t in FINAL if t['teamfta'] is None] t1.sort(key=lambda t: -t['ndets']) t2.sort(key=lambda t: -t['ndets']) K1, K2 = len(t1), len(t2) for i, t in enumerate(t1 + t2 + tX): t['team12'] = (1 if t['teamfta'] == TL else 2) if t['teamfta'] is not None else 0 t['id'] = (i + 1) if i < K1 + K2 else 0 print(f"\n🆔 IDs: команда 1 x{K1} -> 1..{K1} | команда 2 x{K2} -> {K1+1}..{K1+K2}")

assert len(FINAL) <= KTOTALFIELD, f"❌ I2: {len(FINAL)} треков" allg = [r['gidx'] for t in FINAL for r in t['frames'] if r['gidx'] is not None] assert len(allg) == len(set(allg)), "❌ I4: детекция в двух треках" for t in FINAL: fidsd = [r['fid'] for r in t['frames'] if r['gidx'] is not None] assert len(fidsd) == len(set(fidsd)), f"❌ I4: две детекции в кадре (id={t['id']})" gidxd = [r['gidx'] for r in t['frames'] if r['gidx'] is not None] bad = [g for g in gidxd if g in APROW and not bool(FIELDPOOL[APROW[g]])] assert not bad, f"❌ I3: вне-пуловые у id={t['id']}: {bad[:5]}" print(f"✅ Инварианты I2–I4: OK ({len(FINAL)} треков)") print(f"🚩 Старты: минимальный fidstart = {min(t['fidstart'] for t in FINAL)} | " f"треков с fidstart>3: " f"{sum(1 for t in FINAL if t['fid_start'] > 3)}")

assert nassign.get(0, 0) == 10 and nassign.get(1, 0) == 10, ( f"❌ СТОП-КРАН: составы t0={nassign.get(0,0)}/t1={nassign.get(1,0)} != 10/10. " f"Экспорт НЕ выполнен. Пришлите лог (E1/E3-блоки).") print(f"🛑 СТОП-КРАН пройден: составы 10/10, {len(FINAL)} треков, I3/I4 чисты")

srccounter = Counter(r['src'] for t in FINAL for r in t['frames'] if r['gidx'] is not None) print(f"\n📋 ИТОГ — таблица треков:") spans, covs, purs = [], [], [] for t in sorted(FINAL, key=lambda x: x['id']): span = t['fidend'] - t['fidstart'] + 1 cov = t['ndets'] / span spans.append(span); covs.append(cov) if t['puradaptive'] is not None: purs.append(t['puradaptive']) print(f" id={t['id']:2d} ком.{t['team12']} (FTA t{t['teamfta']}" f"{' FIXED' if t['teamfixed'] else ''}) f{t['fidstart']}..{t['fidend']} " f"дет.{t['ndets']:3d} span={span:3d} cov={cov:.2f} " f"x={t['medx'] if t['medx'] is None else round(t['medx'])} " f"purity={t['puradaptive'] if t['puradaptive'] is None else round(t['puradaptive'], 3)} " f"голоса t0:{t['votes'].get(0,0)}/t1:{t['votes'].get(1,0)} " f"(fwd t0:{t['fwdvotes'].get(0,0)}/t1:{t['fwdvotes'].get(1,0)}) " f"частей:{len(t['parts'])}") medspan = float(np.median(spans)); medcov = float(np.median(covs)) medpur = float(np.median(purs)) if purs else None print(f"\n Медианы: длительность {medspan:.0f} | coverage {medcov:.2f} | " f"purity(адапт.) {medpur if medpur is None else round(medpur, 3)}") print(f" Источники детекций: {dict(src_counter)}")

tracksout = [] for t in sorted(FINAL, key=lambda x: x['id']): tracksout.append({ 'id': int(t['id']), 'team': int(t['team12']), 'teamfta': int(t['teamfta']), 'teamfixed': bool(t['teamfixed']), 'teamvotes': {'1': int(t['votes'].get(TL, 0)), '2': int(t['votes'].get(TR, 0))}, 'fwdvotes': {'0': int(t['fwdvotes'].get(0, 0)), '1': int(t['fwdvotes'].get(1, 0))}, 'fidstart': int(t['fidstart']), 'fidend': int(t['fidend']), 'nframes': int(t['ndets']), 'frames': t['frames'], 'protoe': (t['proto'].tolist() if t['proto'] is not None else None), 'vlproto': t['vlproto'], 'parts': t['parts'], 'changepoints': [{'fidcut': c['fidcut'], 'dshift': c['dshift'], 'role': ('donor' if c['tid'] == t['tid'] else 'host')} for c in t['changepoints'] if c.get('action') == 'cut'], 'stats': {'span': int(t['fidend'] - t['fidstart'] + 1), 'coverage': round(t['ndets'] / (t['fidend'] - t['fidstart'] + 1), 3), 'purityadaptive': (round(t['puradaptive'], 3) if t['puradaptive'] is not None else None), 'colgate': round(t['gate'], 3), 'medx': (round(t['medx'], 2) if t['medx'] is not None else None)}}) FIELDTRACKS = tracksout GIDXTOTRACK = {} for t in tracksout: for r in t['frames']: if r['gidx'] is not None: GIDXTOTRACK[int(r['gidx'])] = int(t['id']) ALLTRACKS = [dict(t, kind='field') for t in tracksout] for t in GKTRACKSFIN: gkteam12 = TEAM12OFFTA.get(SIDETEAM.get(t['side'])) ALLTRACKS.append({'id': f"GK{t['side']}", 'kind': 'gk', 'side': t['side'], 'team': gkteam12, 'teamfta': SIDETEAM.get(t['side']), 'fidstart': t['fidstart'], 'fidend': t['fidend'], 'nframes': t['nframes'], 'frames': t['frames']}) for fr in t['frames']: if fr.get('gidx') is not None: GIDXTOTRACK[int(fr['gidx'])] = f"GK{t['side']}" metaout = {'cell': CELLTAG, 'src': SRC, 'fps': FPS, 'k1': K1, 'k2': K2, 'tlfta': TL, 'trfta': TR, 'teams': {'t0': nassign[0], 't1': nassign[1], 'ok1010': bool(okteams)}, 'params': {'TMATCHCOL': TMATCHCOL, 'SUPWIN': SUPWIN, 'FWDSTRONGMAJ': FWDSTRONGMAJ, 'STARTRECWIN': STARTRECWIN, 'FIELDPLAYERSPERTEAM': FIELDPLAYERSPERTEAM}, 'merge': dict(srccounter), 'arbblockedbyteam': nteamblock, 'prearbfixes': prearblog, 'startrec': startreclog, 'cuts': [c for c in cutlog if c.get('action') == 'cut'], 'cpwarnings': cpwarn, 'balance': balancelog, 'oopcut': oopcut, 'refinement': {'cutvalidation': ncutval, 'hole': nrechole, 'ext': nrecext, 'start': nstartrec, 'sweep': nrecsweep, 'poolleft': len(poolrows)}, 'metrics': {'medianspan': medspan, 'mediancoverage': medcov, 'medianpurityadaptive': medpur}} with open(FINPATH, 'w', encoding='utf-8') as f: json.dump({'meta': metaout, 'tracks': tracksout}, f, ensureascii=False, separators=(',', ':')) print(f"\n💾 {FINPATH}") fta2frames = {} for t in tracksout: for r in t['frames']: if r['gidx'] is None: continue row = APROW[r['gidx']] fta2frames.setdefault(r['fid'], []).append( {'gidx': r['gidx'], 'classid': int(APCLS[row]), 'team': int(t['teamfta']), 'trackid': int(t['id'])}) for t in GKTRACKSFIN: for fr in t['frames']: g = fr.get('gidx') if g is None or g not in APROW: continue fta2frames.setdefault(int(fr['fid']), []).append( {'gidx': int(g), 'classid': int(APCLS[APROW[int(g)]]), 'team': int(SIDETEAM.get(t['side'])), 'trackid': f"GK{t['side']}"}) fta2 = {'meta': {'cell': CELLTAG, 'source': 'fieldtracksfinal(v5.5) + gktracksfinal', 'teamscale': 'FTA (0/1)', 'teammapto12': {str(TL): 1, str(TR): 2}, 'trackidscale': '1..K1 (команда 1), K1+1..K1+K2 (команда 2), GKL/GKR', 'nframes': len(fta2frames), 'nrecords': sum(len(v) for v in fta2frames.values())}, 'frames': {str(k): v for k, v in sorted(fta2frames.items())}} with open(FTA2PATH, 'w', encoding='utf-8') as f: json.dump(fta2, f, ensureascii=False, separators=(',', ':')) print(f"💾 {FTA2PATH} ({fta2['meta']['nrecords']} записей)") print(f"\n Глобали: FIELDTRACKS ({len(FIELDTRACKS)}), ALLTRACKS ({len(ALLTRACKS)}), " f"GIDXTOTRACK ({len(GIDXTOTRACK)}) | ОЗУ {ramgb():.2f} ГБ") gc.collect() print(f"\n✅ {CELLTAG} готов ({time.perfcounter() - t00:.1f} c). " "Следующие: 31F v7 (страховка) -> 32F v4 (верификация: старты, FIXED-трек).")

@title 31F v5.8 (patch). Хирургия id20: РАЗРЕЗ по «последней хорошей» (точка

невозврата) вместо «первого блока из 5»

#

v5.8 = v5.7 + одно исправление [F1']:

[F1'] ПРИЧИНА f196 (лог v5.7): критерий «первый блок из 5 подряд чужих»

срабатывает на локальное вкрапление в плотной зоне (по D5 Q2 f187-373

на ~95% своя, но с редкими чужими tid11; блок из 5 чужих подряд в

f192-196 — вкрапление, не граница). Фикс: точка невозврата —

fidcut = конец последнего GOODWIN(60)-кадрового окна, содержащего

детекцию с fwd из GOOD_FWDS. Всё ПОСЛЕ него — чужой хвост (по D5:

последняя хорошая ~f373 -> fid_cut ~374). Одинокие хорошие детекции

в чужом хвосте (дальше GOOD_WIN от основной массы) игнорируются.

Унаследовано: [F2] возврат id16 ДО разреза по полному треку; None нейтральны;

[F3] защита от огрызка (MINKEEPDETS); [S2] отбор для [7,8,9]; стоп-кран;

пересборка; экспорт.

Порядок: 31F v5.5 (база) -> 31F v5.8. Без видео; секунды.

import os, gc, json, time, math import numpy as np from collections import Counter, defaultdict

t00 = time.perfcounter() CELLTAG = '31F v5.8'

================== ПАРАМЕТРЫ ==================

TARGETID = 20 GOODFWDS = {13, 17} WHITEIDS = [7, 8, 9] GOODWIN = 60 # [F1']: окно «регулярности» хороших детекций MINKEEPDETS = 150 # [F3] RECOVERPOSM = 4.0 KCOLADAPT, COLGATECAP = 2.5, 3.0 TMATCHCOL = float(globals().get('TMATCHCOL', 1.5)) VMAXKF = float(globals().get('VMAXKF', 9.0)) POSGATEM = 6.0 RTSQ, RTSR = 6.0, 0.20 MINTRACKDETS = 5 VOTEMIN, VOTEMAJ = 3, 0.60

CACHEDIR = str(globals().get('CACHEDIR', '/content/cache')) OUTPUTDIR = str(globals().get('OUTPUTDIR', '/content/output')) FINPATH = os.path.join(OUTPUTDIR, 'fieldtracksfinal.json') FWDPATH = os.path.join(OUTPUTDIR, 'fieldtracksfwd.json') GKFINPATH = os.path.join(OUTPUTDIR, 'gktracksfinal.json') FTA2PATH = os.path.join(OUTPUTDIR, 'frameteamassignment_v2.json')

=====================================================

1. Данные

=====================================================

assert os.path.exists(FINPATH), f"❌ {FINPATH}" with open(FINPATH, encoding='utf-8') as f: BASE = json.load(f) basecell = str(BASE.get('meta', {}).get('cell', '')) assert basecell.startswith('31F v5.5'), ( f"❌ база от '{basecell}', нужен v5.5. ПЕРЕЗАПУСТИТЕ 31F v5.5, затем v5.8.") TR = {int(t['id']): t for t in BASE['tracks']} with open(FWDPATH, encoding='utf-8') as f: FWD = json.load(f)['tracks'] with open(GKFINPATH, encoding='utf-8') as f: GKFULL = json.load(f) GKTRACKSFIN = GKFULL['tracks'] with np.load(os.path.join(CACHEDIR, 'appearancecache.npz')) as z: APGIDX = z['gidx'].astype(np.int64); APFID = z['fid'].astype(np.int32) APCLS = z['cls'].astype(np.int8); APCONF = z['conf'].astype(np.float32) APX1 = z['x1'].astype(np.float32); APY1 = z['y1'].astype(np.float32) APX2 = z['x2'].astype(np.float32); APY2 = z['y2'].astype(np.float32) APPX = z['pitchx'].astype(np.float32); APPY = z['pitchy'].astype(np.float32) APPROJ = z['proj'].astype(bool); APCOLOR12 = z['color12'].astype(np.float32) NAP = len(APGIDX) APROW = {int(g): i for i, g in enumerate(APGIDX.tolist())} with open(os.path.join(OUTPUTDIR, 'frameteamassignment.json'), encoding='utf-8') as f: fta = json.load(f) teamof = {} for recs in fta.get('frames', {}).values(): for r in recs: teamof[int(r['gidx'])] = int(r['team']) APTEAM = np.full(NAP, -1, np.int8) for i, g in enumerate(APGIDX.tolist()): t = teamof.get(int(g)) if t is not None: APTEAM[i] = t with open(os.path.join(OUTPUTDIR, 'teamprototypes.json'), encoding='utf-8') as f: PROTO = json.load(f) BLOCKS = PROTO.get('blocks', []) CENTS = np.asarray(PROTO['centroidsscaled'], np.float32) def embed(c12): vs = [] for b in BLOCKS: s0, s1 = int(b['slice'][0]), int(b['slice'][1]) keep = np.asarray(b['keepdims'], int) part = c12[:, s0:s1][:, keep] vs.append(((part - np.asarray(b['mean'], np.float32)) / np.maximum(np.asarray(b['scale'], np.float32), 1e-6)).astype(np.float32) / np.sqrt(max(1, len(keep)))) return np.hstack(vs) if len(vs) > 1 else vs[0] EALL = embed(APCOLOR12) fwdtidofgidx = {} for t in FWD: for r in t['frames']: if r.get('gidx') is not None: fwdtidofgidx[int(r['gidx'])] = int(t['tid']) gkexcl = {int(fr['gidx']) for t in GKTRACKSFIN for fr in t['frames'] if fr.get('gidx') is not None} gkcut = {int(c['gidx']) for c in GKFULL.get('cutlog', [])} gkxarr = (np.isin(APGIDX, np.asarray(sorted(gkexcl)), np.int64) if gkexcl else np.zeros(NAP, bool)) cutxarr = (np.isin(APGIDX, np.asarray(sorted(gkcut)), np.int64) if gkcut else np.zeros(NAP, bool)) FIELDPOOL = np.isin(APCLS, [1, 2]) & (~gkxarr) & (~cutxarr) & (APCLS != 3) FIDS = sorted({int(r['fid']) for t in TR.values() for r in t['frames']}) FPS = float(BASE.get('meta', {}).get('fps', 25.0))

SIDETEAM = {} for t in GKTRACKSFIN: rows = [APROW[int(fr['gidx'])] for fr in t['frames'] if fr.get('gidx') is not None and int(fr['gidx']) in APROW] Ev = EALL[rows]; Ev = Ev[np.isfinite(Ev).all(1)] if len(Ev) >= 3: d0 = float(np.linalg.norm(Ev.mean(0) - CENTS[0])) d1 = float(np.linalg.norm(Ev.mean(0) - CENTS[1])) SIDETEAM[t['side']] = 0 if d0 <= d1 else 1 if len(SIDETEAM) == 2 and SIDETEAM.get('L') == SIDETEAM.get('R'): medx = {tm: float(np.nanmedian(APPX[(APTEAM == tm)])) for tm in (0, 1)} SIDETEAM['L'] = 0 if medx[0] <= medx[1] else 1 SIDETEAM['R'] = 1 - SIDETEAM['L'] TL, TR = SIDETEAM.get('L'), SIDETEAM.get('R') TEAM12OFFTA = {TL: 1, TR: 2} print(f"⚙️ {CELLTAG}: база = {basecell} ({len(TR)} треков) | цель id{TARGETID} " f"(хорошие fwd {sorted(GOODFWDS)}) | критерий разреза: последняя хорошая " f"(окно {GOODWIN}) | отбор для {WHITEIDS} | T(L)=t{TL} -> команда 1")

=====================================================

2. Утилиты

=====================================================

def rtssmooth(detbyfid, flo, fhi): fids = list(range(int(flo), int(fhi) + 1)) n = len(fids) if n == 0 or not detbyfid: return {} dt = 1.0 / FPS F = np.array([[1, 0, dt, 0], [0, 1, 0, dt], [0, 0, 1, 0], [0, 0, 0, 1]], np.float64) Q = RTSQ np.array([[dt4/4, 0, dt3/2, 0], [0, dt4/4, 0, dt3/2], [dt3/2, 0, dt2, 0], [0, dt3/2, 0, dt2]], np.float64) H = np.array([[1., 0, 0, 0], [0, 1., 0, 0]], np.float64) R = np.eye(2) RTSR I4 = np.eye(4) ffirst = min(detbyfid) x = np.array([detbyfid[ffirst][0], detbyfid[ffirst][1], 0.0, 0.0], np.float64) P = np.diag([0.5, 0.5, 9.0, 9.0]) if ffirst == fids[0] else np.diag([25.0, 25.0, 9.0, 9.0]) xf = [None] * n; Pf = [None] * n; xp = [None] * n; Pp = [None] * n for i, f in enumerate(fids): if i > 0: x = F @ x P = F @ P @ F.T + Q xp[i] = x.copy(); Pp[i] = P.copy() if f in detbyfid: z = np.array(detbyfid[f], np.float64) S = H @ P @ H.T + R K = P @ H.T @ np.linalg.inv(S) x = x + K @ (z - H @ x) IKH = I4 - K @ H P = IKH @ P @ IKH.T + K @ R @ K.T xf[i] = x.copy(); Pf[i] = P.copy() xs = [None] * n xs[n - 1] = xf[n - 1].copy() for i in range(n - 2, -1, -1): C = Pf[i] @ F.T @ np.linalg.inv(Pp[i + 1]) xs[i] = xf[i] + C @ (xs[i + 1] - xp[i + 1]) out = {} for i, f in enumerate(fids): s = xs[i] vx, vy = float(s[2]), float(s[3]) v = math.hypot(vx, vy) if v > VMAXKF: vx = VMAX_KF / v; vy = VMAX_KF / v out[f] = (float(s[0]), float(s[1]), vx, vy) return out

def medmadgate(vals): v = np.asarray([x for x in vals if x is not None and np.isfinite(x)], np.float64) if len(v) == 0: return TMATCHCOL med = float(np.median(v)) mad = float(np.median(np.abs(v - med))) 1.4826 return float(np.clip(med + K_COL_ADAPT mad, TMATCHCOL, COLGATECAP))

def meanE(rows): Ev = EALL[rows] Ev = Ev[np.isfinite(Ev).all(1)] return (Ev.mean(axis=0).astype(np.float32) if len(Ev) else None)

def votesof(rows): return Counter(int(APTEAM[r]) for r in rows if int(AP_TEAM[r]) in (0, 1))

def dgkof(row, proto): e = EALL[row] if proto is None or not np.isfinite(e).all(): return None return float(np.linalg.norm(e - proto))

recs = {} ownerg = {} basemeta = {} for tid, t in TR.items(): recs[tid] = {} basemeta[tid] = {'teamfta': int(t['teamfta']), 'teamfixed': bool(t.get('teamfixed', False))} for r in t['frames']: if r.get('gidx') is not None: recs[tid][int(r['fid'])] = {'gidx': int(r['gidx']), 'src': str(r.get('src', 'v5'))} ownerg[int(r['gidx'])] = tid poolrows = set() pool = defaultdict(set) def pooladd(row, fid): if int(row) not in poolrows: poolrows.add(int(row)) pool[int(fid)].add(int(row)) def pooltake(row): poolrows.discard(int(row)) pool[int(APFID[int(row)])].discard(int(row)) for row in np.where(FIELDPOOL & APPROJ & np.isfinite(APPX) & np.isfinite(APPY))[0]: g = int(APGIDX[int(row)]) if g not in ownerg: pooladd(int(row), int(APFID[int(row)])) print(f"🧩 База: {len(TR)} треков | закреплено {len(ownerg)} | пул {len(pool_rows)}")

def trackrts(tid): fs = sorted(recs[tid]) det = {f: (float(APPX[APROW[recs[tid][f]['gidx']]]), float(APPY[APROW[recs[tid][f]['gidx']]])) for f in fs} return rtssmooth(det, fs[0], fs[-1]), fs[0], fs[-1]

=====================================================

3. [F2] Возврат id16 -> id20 ДО разреза (полный трек)

=====================================================

print(f"\n🚩 [F2] Возврат ранних детекций id16 -> id{TARGETID} (до разреза)") rtsd20, flo20, fhi20 = trackrts(TARGETID) rows20 = [APROW[r['gidx']] for r in recs[TARGETID].values()] proto20 = meanE(rows20) gate20 = medmadgate([dgkof(r, proto20) for r in rows20]) takens3 = 0 for f in sorted(recs[16]): if f in recs[TARGETID]: continue r = recs[16].get(f) if r is None or fwdtidofgidx.get(r['gidx']) not in GOODFWDS: continue if f not in rtsd20: continue row = APROW[r['gidx']] d = math.hypot(float(APPX[row]) - rtsd20[f][0], float(APPY[row]) - rtsd20[f][1]) if d > RECOVERPOSM: continue dg = dgkof(row, proto20) if dg is None or dg > min(gate20, COLGATECAP): continue del recs[16][f] recs[TARGETID][f] = {'gidx': r['gidx'], 'src': 's3return'} ownerg[r['gidx']] = TARGETID takens3 += 1 print(f" Возвращено {takens3} | id{TARGETID}: f{min(recs[TARGETID])}.." f"{max(recs[TARGETID])} ({len(recs[TARGETID])}) | id16: {len(recs[16])}") assert len(recs[16]) >= MINTRACK_DETS, "❌ id16 выродился"

=====================================================

4. [F1'] РАЗРЕЗ: точка невозврата (последняя «регулярная» хорошая)

=====================================================

print(f"\n✂️ [F1'] Разрез id{TARGETID} по последней хорошей (окно регулярности {GOODWIN})") frs = sorted(recs[TARGETID].items()) goodfids = [f for f, r in frs if fwdtidofgidx.get(r['gidx']) in GOODFWDS] print(f" Хороших детекций (fwd в {sorted(GOODFWDS)}): {len(goodfids)} | " f"f{min(goodfids)}..{max(goodfids)}" if good_fids else " хороших нет")

точка невозврата: максимальный fid, такой что в окне [fid-GOOD_WIN+1, fid]

есть хорошая детекция; берём конец последнего «плотного» хорошими окна

lastgood = max(goodfids) if goodfids else None if lastgood is not None: fidcut = lastgood + 1 # защита от одиноких хороших «хвостов»: если между lastgood и предыдущей # хорошей разрыв > GOODWIN — lastgood вкрапление, откатываемся gs = sorted(goodfids) while len(gs) >= 2 and gs[-1] - gs[-2] > GOODWIN: gs.pop() fidcut = gs[-1] + 1 if gs else None ncut = 0 if fidcut is not None: for f in [q for q in recs[TARGETID] if q >= fidcut]: r = recs[TARGETID].pop(f) del ownerg[r['gidx']] pooladd(APROW[r['gidx']], f) ncut += 1 print(f" Последняя регулярная хорошая: f{fidcut - 1} | разрез на f{fidcut} | " f"вырезано {ncut} в пул | остаток f{min(recs[TARGETID])}.." f"{max(recs[TARGETID])} ({len(recs[TARGETID])} дет.)") else: print(f" ⚠️ Хороших детекций нет — разрез не выполняется") assert len(recs[TARGETID]) >= MINKEEPDETS, ( f"❌ СТОП-КРАН [F3]: остаток id{TARGETID} = {len(recs[TARGETID])} < {MINKEEPDETS}. " f"Экспорт НЕ выполнен (fidcut={fidcut}). Пришлите лог.") print(f" ✅ [F3]: остаток {len(recs[TARGETID])} >= {MINKEEP_DETS}")

=====================================================

5. [S2] Отбор освобождённых для [7, 8, 9]

=====================================================

print(f"\n🔗 [S2] Отбор освобождённых детекций для {WHITEIDS}") nrecover = Counter() for wid in WHITEIDS: rtsd, flo, fhi = trackrts(wid) if not rtsd: continue rowsw = [APROW[r['gidx']] for r in recs[wid].values()] proto = meanE(rowsw) gate = medmadgate([dgkof(r, proto) for r in rowsw]) taken = 0 for f in sorted(pool.keys()): if f < flo or f > fhi or f in recs[wid] or not pool.get(f): continue sx, sy = rtsd[f][0], rtsd[f][1] best, bestc = None, None for row in sorted(pool[f]): d = math.hypot(float(APPX[row]) - sx, float(APPY[row]) - sy) if d > RECOVERPOSM: continue dg = dgkof(row, proto) if dg is None or dg > min(gate, COLGATECAP): continue c = d / RECOVERPOSM + dg / max(gate, 1e-6) if bestc is None or c < bestc: bestc, best = c, row if best is not None: recs[wid][f] = {'gidx': int(APGIDX[best]), 'src': 's2recover'} ownerg[int(APGIDX[best])] = wid pooltake(best) taken += 1 nrecover[wid] = taken print(f" id{wid}: +{taken} | f{min(recs[wid])}..{max(recs[wid])}") print(f" Итого: {dict(nrecover)} | в пуле осталось {len(pool_rows)}")

=====================================================

6. Стоп-кран + пересборка + экспорт

=====================================================

for tid in [TARGETID, 16] + WHITEIDS: rowst = [APROW[r['gidx']] for r in recs[tid].values() if r['gidx'] in APROW] c = votesof(rowst) tot = c.get(0, 0) + c.get(1, 0) if tot >= VOTEMIN and max(c.get(0, 0), c.get(1, 0)) / tot >= VOTEMAJ: basemeta[tid]['team_fta'] = 0 if c.get(0, 0) >= c.get(1, 0) else 1

assert len(recs) == 20, f"❌ I2" allg = [r['gidx'] for tid in recs for r in recs[tid].values()] assert len(allg) == len(set(allg)), "❌ I4" for tid in recs: ff = sorted(recs[tid]) assert len(ff) == len(set(ff)), f"❌ I4: tid={tid}" assert len(ff) >= MINTRACKDETS, f"❌ tid={tid} выродился" badpool = [g for g in allg if g in APROW and not bool(FIELDPOOL[APROW[g]])] assert not badpool, f"❌ I3: {badpool[:5]}" nt = Counter(basemeta[tid]['teamfta'] for tid in recs) assert nt.get(0, 0) == 10 and nt.get(1, 0) == 10, ( f"❌ СТОП-КРАН: составы t0={nt.get(0,0)}/t1={n_t.get(1,0)}. Экспорт НЕ выполнен.") print(f"\n🛑 СТОП-КРАН: 20 треков, 10/10, I3/I4 чисты")

def rebuild(tid): rowst = [APROW[r['gidx']] for r in recs[tid].values() if r['gidx'] in APROW] proto = meanE(rowst) fs = sorted(recs[tid]) det = {f: (float(APPX[APROW[recs[tid][f]['gidx']]]), float(APPY[APROW[recs[tid][f]['gidx']]])) for f in fs} rtsd = rtssmooth(det, fs[0], fs[-1]) gate = medmadgate([dgkof(r, proto) for r in rowst]) dgs, pes = [], [] for f in fs: row = APROW[recs[tid][f]['gidx']] dg = dgkof(row, proto) if dg is not None: dgs.append(dg) pes.append(math.hypot(float(APPX[row]) - rtsd[f][0], float(APPY[row]) - rtsd[f][1])) pur = float(np.mean((np.asarray(dgs) <= gate) & (np.asarray(pes[:len(dgs)]) <= POSGATEM))) if dgs else None parts = [] for f in fs: src = recs[tid][f]['src'] if parts and parts[-1]['src'] == src and f == parts[-1]['fidend'] + 1: parts[-1]['fidend'] = f else: parts.append({'fidstart': f, 'fidend': f, 'src': src}) framesout = [] for f in range(fs[0], fs[-1] + 1): sx, sy, vx, vy = rtsd[f] r = recs[tid].get(f) if r is not None: row = APROW[r['gidx']] framesout.append({'fid': f, 'gidx': r['gidx'], 'bbox': [round(float(APX1[row]), 1), round(float(APY1[row]), 1), round(float(APX2[row]), 1), round(float(APY2[row]), 1)], 'px': round(float(APPX[row]), 2), 'py': round(float(APPY[row]), 2), 'sx': round(sx, 2), 'sy': round(sy, 2), 'vx': round(vx, 2), 'vy': round(vy, 2), 'src': r['src']}) else: framesout.append({'fid': f, 'gidx': None, 'bbox': None, 'px': None, 'py': None, 'sx': round(sx, 2), 'sy': round(sy, 2), 'vx': round(vx, 2), 'vy': round(vy, 2), 'src': 'gap'}) return {'id': tid, 'team': TEAM12OFFTA.get(basemeta[tid]['teamfta'], 0), 'teamfta': basemeta[tid]['teamfta'], 'teamfixed': basemeta[tid]['teamfixed'], 'frames': framesout, 'protoe': (proto.tolist() if proto is not None else None), 'parts': parts, 'fidstart': fs[0], 'fidend': fs[-1], 'nframes': len(fs), 'stats': {'span': fs[-1] - fs[0] + 1, 'coverage': round(len(fs) / (fs[-1] - fs[0] + 1), 3), 'purityadaptive': (round(pur, 3) if pur is not None else None), 'col_gate': round(gate, 3)}}

changed = [TARGETID, 16] + [w for w in WHITEIDS if n_recover.get(w, 0) > 0] for tid in changed: TR[tid] = rebuild(tid) print(f"🔁 Пересобраны: {changed}")

print(f"\n📋 Итог (изменённые):") for tid in changed: t = TR[tid] rowst = [APROW[r['gidx']] for r in recs[tid].values() if r['gidx'] in APROW] votes = votesof(rowst) span = t['fidend'] - t['fidstart'] + 1 print(f" id={tid:2d} ком.{t['team']} f{t['fidstart']}..{t['fidend']} " f"дет.{t['nframes']:3d} cov={t['nframes']/span:.2f} " f"purity={t['stats']['purityadaptive']} " f"голоса t0:{votes.get(0,0)}/t1:{votes.get(1,0)}")

tracksout = [] for tid in sorted(TR.keys()): t = TR[tid] rowst = [APROW[r['gidx']] for r in recs[tid].values() if r['gidx'] in APROW] votes = votesof(rowst) tracksout.append({ 'id': int(tid), 'team': int(t['team']), 'teamfta': int(t['teamfta']), 'teamfixed': bool(t.get('teamfixed', False)), 'teamvotes': {'1': int(votes.get(TL, 0)), '2': int(votes.get(TR, 0))}, 'fidstart': int(t['fidstart']), 'fidend': int(t['fidend']), 'nframes': int(t['nframes']), 'frames': t['frames'], 'protoe': t.get('protoe'), 'parts': t.get('parts', []), 'stats': t.get('stats', {})}) FIELDTRACKS = tracksout GIDXTOTRACK = {} for t in tracksout: for r in t['frames']: if r['gidx'] is not None: GIDXTOTRACK[int(r['gidx'])] = int(t['id']) ALLTRACKS = [dict(t, kind='field') for t in tracksout] for t in GKTRACKSFIN: gkteam12 = TEAM12OFFTA.get(SIDETEAM.get(t['side'])) ALLTRACKS.append({'id': f"GK{t['side']}", 'kind': 'gk', 'side': t['side'], 'team': gkteam12, 'teamfta': SIDETEAM.get(t['side']), 'fidstart': t['fidstart'], 'fidend': t['fidend'], 'nframes': t['nframes'], 'frames': t['frames']}) for fr in t['frames']: if fr.get('gidx') is not None: GIDXTOTRACK[int(fr['gidx'])] = f"GK{t['side']}" metaout = dict(BASE.get('meta', {})) metaout.update({'cell': CELLTAG, 'base': basecell, 's8': {'target': TARGETID, 'goodfwds': sorted(GOODFWDS), 'goodwin': GOODWIN, 'fidcut': fidcut, 'ncut': ncut, 's3returned': takens3, 'recovered': dict(nrecover), 'poolleft': len(poolrows)}, 'teams': {'t0': 10, 't1': 10, 'ok1010': True}}) with open(FINPATH, 'w', encoding='utf-8') as f: json.dump({'meta': metaout, 'tracks': tracksout}, f, ensureascii=False, separators=(',', ':')) print(f"\n💾 {FINPATH}") fta2frames = {} for t in tracksout: for r in t['frames']: if r['gidx'] is None: continue row = APROW[r['gidx']] fta2frames.setdefault(r['fid'], []).append( {'gidx': r['gidx'], 'classid': int(APCLS[row]), 'team': int(t['teamfta']), 'trackid': int(t['id'])}) for t in GKTRACKSFIN: for fr in t['frames']: g = fr.get('gidx') if g is None or g not in APROW: continue fta2frames.setdefault(int(fr['fid']), []).append( {'gidx': int(g), 'classid': int(APCLS[APROW[int(g)]]), 'team': int(SIDETEAM.get(t['side'])), 'trackid': f"GK{t['side']}"}) fta2 = {'meta': {'cell': CELLTAG, 'source': 'fieldtracksfinal(v5.8) + gktracksfinal', 'teamscale': 'FTA (0/1)', 'teammapto12': {str(TL): 1, str(TR): 2}, 'nframes': len(fta2frames), 'nrecords': sum(len(v) for v in fta2frames.values())}, 'frames': {str(k): v for k, v in sorted(fta2frames.items())}} with open(FTA2PATH, 'w', encoding='utf-8') as f: json.dump(fta2, f, ensureascii=False, separators=(',', ':')) print(f"💾 {FTA2PATH} ({fta2['meta']['nrecords']} записей)") print(f"\n Глобали: FIELDTRACKS ({len(FIELDTRACKS)}), ALLTRACKS ({len(ALLTRACKS)}), " f"GIDXTOTRACK ({len(GIDXTOTRACK)})") gc.collect() print(f"\n✅ {CELLTAG} готов ({time.perfcounter() - t00:.1f} c). " "Проверка: 32F v4 — TRACKIDSELECT=20 / 7 / 16 / 15.")

@title 31F v5.9 (final patch). id7: достройка W-хвоста без цветового гейта;

id15: изъятие чужих tid13-детекций; id20: удлинение тёмными пул-цепочками

#

По 32F-D6 + видео: W (белый, tid4, fwd id7) мерцает в тёмную в штрафной ->

гейт блокировал его t0-метки -> хвост в пуле (269 + цепочки у лицевой).

Владелец id20 (тёмный, старт (70,44)) уходит по дуге к лицевой выше вратарской

(видео, зелёная траектория) = пул-цепочки f480..749.

[W1] id7 ХВОСТ: присоединение пул-детекций f>=WTAILF: fwd-владение tid4

ИЛИ позиция <= 5 м от RTS id7; БЕЗ цветового и командного гейта (цвет W

в штрафной недостоверен — видео + D6). Ограничение: <=1 на кадр.

[W2] id15 ЧИСТКА: детекции с fwd-владением tid13 (чужие) изымаются в пул

(id15 остаётся чистым tid11; конец исправляется).

[W3] id20 УДЛИНЕНИЕ: тёмные пул-цепочки (кластеры пула в зоне x>=85,

f >= f_end(id20)-30) присоединяются по позиционной непрерывности от

RTS id20 (прогноз вперёд); БЕЗ жёсткого цветового гейта (цепочки уже

проверены: все t0, тёмные). Покадрово: <=1 на кадр, ближайшая к прогнозу.

Стоп-кран: 20/10-10/I3/I4; пересборка id7/id15/id20; экспорт.

Порядок: v5.5 (база) -> v5.8 (разрез id20) -> v5.9. Без видео; секунды.

import os, gc, json, time, math import numpy as np from collections import Counter, defaultdict

t00 = time.perfcounter() CELLTAG = '31F v5.9'

================== ПАРАМЕТРЫ ==================

WID, WFWD, WTAILF, WPOSM = 7, 4, 450, 5.0 CLEANID, CLEANFWD = 15, 13 EXTID, EXTMINX, EXTBACK = 20, 85.0, 30 RECOVERPOSM = 4.0 KCOLADAPT, COLGATECAP = 2.5, 3.0 TMATCHCOL = float(globals().get('TMATCHCOL', 1.5)) VMAXKF = float(globals().get('VMAXKF', 9.0)) POSGATEM = 6.0 RTSQ, RTSR = 6.0, 0.20 MINTRACKDETS = 5 VOTEMIN, VOTEMAJ = 3, 0.60

CACHEDIR = str(globals().get('CACHEDIR', '/content/cache')) OUTPUTDIR = str(globals().get('OUTPUTDIR', '/content/output')) FINPATH = os.path.join(OUTPUTDIR, 'fieldtracksfinal.json') FWDPATH = os.path.join(OUTPUTDIR, 'fieldtracksfwd.json') GKFINPATH = os.path.join(OUTPUTDIR, 'gktracksfinal.json') FTA2PATH = os.path.join(OUTPUTDIR, 'frameteamassignment_v2.json')

=====================================================

1. Данные

=====================================================

assert os.path.exists(FINPATH), f"❌ {FINPATH}" with open(FINPATH, encoding='utf-8') as f: BASE = json.load(f) basecell = str(BASE.get('meta', {}).get('cell', '')) assert basecell.startswith('31F v5.8'), ( f"❌ база от '{basecell}', нужен v5.8. Порядок: 31F v5.5 -> v5.8 -> v5.9.") TR = {int(t['id']): t for t in BASE['tracks']} with open(FWDPATH, encoding='utf-8') as f: FWD = json.load(f)['tracks'] with open(GKFINPATH, encoding='utf-8') as f: GKFULL = json.load(f) GKTRACKSFIN = GKFULL['tracks'] with np.load(os.path.join(CACHEDIR, 'appearancecache.npz')) as z: APGIDX = z['gidx'].astype(np.int64); APFID = z['fid'].astype(np.int32) APCLS = z['cls'].astype(np.int8); APCONF = z['conf'].astype(np.float32) APX1 = z['x1'].astype(np.float32); APY1 = z['y1'].astype(np.float32) APX2 = z['x2'].astype(np.float32); APY2 = z['y2'].astype(np.float32) APPX = z['pitchx'].astype(np.float32); APPY = z['pitchy'].astype(np.float32) APPROJ = z['proj'].astype(bool); APCOLOR12 = z['color12'].astype(np.float32) NAP = len(APGIDX) APROW = {int(g): i for i, g in enumerate(APGIDX.tolist())} with open(os.path.join(OUTPUTDIR, 'frameteamassignment.json'), encoding='utf-8') as f: fta = json.load(f) teamof = {} for recs in fta.get('frames', {}).values(): for r in recs: teamof[int(r['gidx'])] = int(r['team']) APTEAM = np.full(NAP, -1, np.int8) for i, g in enumerate(APGIDX.tolist()): t = teamof.get(int(g)) if t is not None: APTEAM[i] = t with open(os.path.join(OUTPUTDIR, 'teamprototypes.json'), encoding='utf-8') as f: PROTO = json.load(f) BLOCKS = PROTO.get('blocks', []) CENTS = np.asarray(PROTO['centroidsscaled'], np.float32) def embed(c12): vs = [] for b in BLOCKS: s0, s1 = int(b['slice'][0]), int(b['slice'][1]) keep = np.asarray(b['keepdims'], int) part = c12[:, s0:s1][:, keep] vs.append(((part - np.asarray(b['mean'], np.float32)) / np.maximum(np.asarray(b['scale'], np.float32), 1e-6)).astype(np.float32) / np.sqrt(max(1, len(keep)))) return np.hstack(vs) if len(vs) > 1 else vs[0] EALL = embed(APCOLOR12) fwdtidofgidx = {} for t in FWD: for r in t['frames']: if r.get('gidx') is not None: fwdtidofgidx[int(r['gidx'])] = int(t['tid']) gkexcl = {int(fr['gidx']) for t in GKTRACKSFIN for fr in t['frames'] if fr.get('gidx') is not None} gkcut = {int(c['gidx']) for c in GKFULL.get('cutlog', [])} gkxarr = (np.isin(APGIDX, np.asarray(sorted(gkexcl)), np.int64) if gkexcl else np.zeros(NAP, bool)) cutxarr = (np.isin(APGIDX, np.asarray(sorted(gkcut)), np.int64) if gkcut else np.zeros(NAP, bool)) FIELDPOOL = np.isin(APCLS, [1, 2]) & (~gkxarr) & (~cutxarr) & (APCLS != 3) FIDS = sorted({int(r['fid']) for t in TR.values() for r in t['frames']}) FPS = float(BASE.get('meta', {}).get('fps', 25.0))

SIDETEAM = {} for t in GKTRACKSFIN: rows = [APROW[int(fr['gidx'])] for fr in t['frames'] if fr.get('gidx') is not None and int(fr['gidx']) in APROW] Ev = EALL[rows]; Ev = Ev[np.isfinite(Ev).all(1)] if len(Ev) >= 3: d0 = float(np.linalg.norm(Ev.mean(0) - CENTS[0])) d1 = float(np.linalg.norm(Ev.mean(0) - CENTS[1])) SIDETEAM[t['side']] = 0 if d0 <= d1 else 1 if len(SIDETEAM) == 2 and SIDETEAM.get('L') == SIDETEAM.get('R'): medx = {tm: float(np.nanmedian(APPX[(APTEAM == tm)])) for tm in (0, 1)} SIDETEAM['L'] = 0 if medx[0] <= medx[1] else 1 SIDETEAM['R'] = 1 - SIDETEAM['L'] TL, TR = SIDETEAM.get('L'), SIDETEAM.get('R') TEAM12OFFTA = {TL: 1, TR: 2} print(f"⚙️ {CELLTAG}: база = {basecell} ({len(TR)} треков) | " f"W1: id{WID} хвост (fwd tid{WFWD}, f>={WTAILF}, без цветового гейта) | " f"W2: id{CLEANID} чистка tid{CLEANFWD} | " f"W3: id{EXTID} удлинение пул-цепочками (x>={EXTMINX}) | " f"T(L)=t{T_L} -> команда 1")

=====================================================

2. Утилиты + рабочее состояние

=====================================================

def rtssmooth(detbyfid, flo, fhi): fids = list(range(int(flo), int(fhi) + 1)) n = len(fids) if n == 0 or not detbyfid: return {} dt = 1.0 / FPS F = np.array([[1, 0, dt, 0], [0, 1, 0, dt], [0, 0, 1, 0], [0, 0, 0, 1]], np.float64) Q = RTSQ np.array([[dt4/4, 0, dt3/2, 0], [0, dt4/4, 0, dt3/2], [dt3/2, 0, dt2, 0], [0, dt3/2, 0, dt2]], np.float64) H = np.array([[1., 0, 0, 0], [0, 1., 0, 0]], np.float64) R = np.eye(2) RTSR I4 = np.eye(4) ffirst = min(detbyfid) x = np.array([detbyfid[ffirst][0], detbyfid[ffirst][1], 0.0, 0.0], np.float64) P = np.diag([0.5, 0.5, 9.0, 9.0]) if ffirst == fids[0] else np.diag([25.0, 25.0, 9.0, 9.0]) xf = [None] * n; Pf = [None] * n; xp = [None] * n; Pp = [None] * n for i, f in enumerate(fids): if i > 0: x = F @ x P = F @ P @ F.T + Q xp[i] = x.copy(); Pp[i] = P.copy() if f in detbyfid: z = np.array(detbyfid[f], np.float64) S = H @ P @ H.T + R K = P @ H.T @ np.linalg.inv(S) x = x + K @ (z - H @ x) IKH = I4 - K @ H P = IKH @ P @ IKH.T + K @ R @ K.T xf[i] = x.copy(); Pf[i] = P.copy() xs = [None] * n xs[n - 1] = xf[n - 1].copy() for i in range(n - 2, -1, -1): C = Pf[i] @ F.T @ np.linalg.inv(Pp[i + 1]) xs[i] = xf[i] + C @ (xs[i + 1] - xp[i + 1]) out = {} for i, f in enumerate(fids): s = xs[i] vx, vy = float(s[2]), float(s[3]) v = math.hypot(vx, vy) if v > VMAXKF: vx = VMAX_KF / v; vy = VMAX_KF / v out[f] = (float(s[0]), float(s[1]), vx, vy) return out

def medmadgate(vals): v = np.asarray([x for x in vals if x is not None and np.isfinite(x)], np.float64) if len(v) == 0: return TMATCHCOL med = float(np.median(v)) mad = float(np.median(np.abs(v - med))) 1.4826 return float(np.clip(med + K_COL_ADAPT mad, TMATCHCOL, COLGATECAP))

def meanE(rows): Ev = EALL[rows] Ev = Ev[np.isfinite(Ev).all(1)] return (Ev.mean(axis=0).astype(np.float32) if len(Ev) else None)

def votesof(rows): return Counter(int(APTEAM[r]) for r in rows if int(AP_TEAM[r]) in (0, 1))

def dgkof(row, proto): e = EALL[row] if proto is None or not np.isfinite(e).all(): return None return float(np.linalg.norm(e - proto))

recs = {} ownerg = {} basemeta = {} for tid, t in TR.items(): recs[tid] = {} basemeta[tid] = {'teamfta': int(t['teamfta']), 'teamfixed': bool(t.get('teamfixed', False))} for r in t['frames']: if r.get('gidx') is not None: recs[tid][int(r['fid'])] = {'gidx': int(r['gidx']), 'src': str(r.get('src', 'v5'))} ownerg[int(r['gidx'])] = tid poolrows = set() pool = defaultdict(set) def pooladd(row, fid): if int(row) not in poolrows: poolrows.add(int(row)) pool[int(fid)].add(int(row)) def pooltake(row): poolrows.discard(int(row)) pool[int(APFID[int(row)])].discard(int(row)) for row in np.where(FIELDPOOL & APPROJ & np.isfinite(APPX) & np.isfinite(APPY))[0]: g = int(APGIDX[int(row)]) if g not in ownerg: pooladd(int(row), int(APFID[int(row)])) print(f"🧩 База: {len(TR)} треков | закреплено {len(ownerg)} | пул {len(pool_rows)}")

def trackrts(tid): fs = sorted(recs[tid]) det = {f: (float(APPX[APROW[recs[tid][f]['gidx']]]), float(APPY[APROW[recs[tid][f]['gidx']]])) for f in fs} return rtssmooth(det, fs[0], fs[-1]), fs[0], fs[-1]

=====================================================

3. [W2] Чистка id15 от чужих tid13-детекций (до W1/W3 — освобождает пул)

=====================================================

print(f"\n🧹 [W2] id{CLEANID}: изъятие детекций с fwd-владением tid{CLEANFWD}") removed = 0 for f in sorted(recs[CLEANID]): r = recs[CLEANID][f] if fwdtidofgidx.get(r['gidx']) == CLEANFWD: del recs[CLEANID][f] del ownerg[r['gidx']] pooladd(APROW[r['gidx']], f) removed += 1 print(f" Изъято {removed} дет. (fwd tid{CLEANFWD}) -> пул | id{CLEANID} остался " f"{len(recs[CLEANID])} дет., f{min(recs[CLEANID])}..{max(recs[CLEANID])}") assert len(recs[CLEANID]) >= MINTRACKDETS, "❌ id15 выродился после чистки"

=====================================================

4. [W1] Достройка хвоста id7 (W): без цветового/командного гейта

=====================================================

print(f"\n🔗 [W1] id{WID}: достройка хвоста из пула (f>={WTAILF}; fwd tid{WFWD} " f"или позиция <= {WPOSM} м от RTS; БЕЗ цветового гейта)") addedw = 0 for it in range(3): # итерации: RTS уточняется после каждой rtsd, flo, fhi = trackrts(WID) if not rtsd: break changed = False # дыры внутри диапазона + продление за fhi fstart = max(WTAILF, flo) fend = FIDS[-1] for f in range(fstart, fend + 1): if f in recs[WID]: continue if f > fhi + 60: # далеко за прогнозом — стоп break if not pool.get(f): continue # прогноз: RTS или экстраполяция скорости if f in rtsd: sx, sy = rtsd[f][0], rtsd[f][1] else: ks = sorted(rtsd) sx, sy, vx, vy = rtsd[ks[-1]] dfr = (f - ks[-1]) / FPS ex, ey = vx * dfr, vy * dfr nrm = math.hypot(ex, ey) if nrm > 15.0: ex *= 15.0 / nrm; ey *= 15.0 / nrm sx, sy = sx + ex, sy + ey best, bestc = None, None for row in sorted(pool[f], key=lambda q: -float(APCONF[q])): d = math.hypot(float(APPX[row]) - sx, float(APPY[row]) - sy) isfwd = (fwdtidofgidx.get(int(APGIDX[row])) == WFWD) if not isfwd and d > WPOSM: continue if isfwd and d > 12.0: continue # fwd-свои: мягче по позиции c = d + (0.0 if isfwd else 5.0) # приоритет fwd-своим if bestc is None or c < bestc: bestc, best = c, row if best is not None: g = int(APGIDX[best]) recs[WID][f] = {'gidx': g, 'src': 'w1tail'} ownerg[g] = WID pooltake(best) addedw += 1 changed = True if not changed: break print(f" Добавлено {addedw} дет. | id{WID}: " f"f{min(recs[WID])}..{max(recs[WID])} ({len(recs[WID])} дет.)") tailw = [recs[WID][f] for f in sorted(recs[WID]) if f >= WTAILF] if tailw: xs = [float(APPX[APROW[r['gidx']]]) for r in tailw] ys = [float(APPY[APROW[r['gidx']]]) for r in tail_w] print(f" Хвост: x {min(xs):.0f}..{max(xs):.0f}, y {min(ys):.0f}..{max(ys):.0f} | " f"до лицевой выше вратарской (x>=99, y<=28): " f"{'ДА ✅' if max(xs) >= 99 and min(ys) <= 28 else 'частично'}")

=====================================================

5. [W3] Удлинение id20 тёмными пул-цепочками

=====================================================

print(f"\n➕ [W3] id{EXTID}: удлинение из пула (зона x>={EXTMINX}, " f"f >= конец-{EXTBACK}; тёмные цепочки по видео)") addede = 0 for it in range(4): rtsd, flo, fhi = trackrts(EXTID) if not rtsd: break rowse = [APROW[r['gidx']] for r in recs[EXTID].values()] protoe = meanE(rowse) gatee = medmadgate([dgkof(r, protoe) for r in rowse]) changed = False ffrom = max(FIDS[0], fhi - EXTBACK) for f in range(fhi + 1, FIDS[-1] + 1): if f in recs[EXTID] or not pool.get(f): continue if f > fhi + 60: break ks = sorted(rtsd) sx, sy, vx, vy = rtsd[ks[-1]] dfr = (f - ks[-1]) / FPS ex, ey = vx * dfr, vy * dfr nrm = math.hypot(ex, ey) if nrm > 15.0: ex *= 15.0 / nrm; ey *= 15.0 / nrm px, py = sx + ex, sy + ey best, bestc = None, None for row in sorted(pool[f], key=lambda q: -float(APCONF[q])): x, y = float(APPX[row]), float(APPY[row]) if x < EXTMINX: continue # только зона лицевой (по видео/цепочкам D6) d = math.hypot(x - px, y - py) if d > 12.0: continue dg = dgkof(row, protoe) if dg is None or dg > max(gatee, COLGATECAP): continue # цвет: тёмный к тёмному (мягкий потолок) c = d if bestc is None or c < bestc: bestc, best = c, row if best is not None: g = int(APGIDX[best]) recs[EXTID][f] = {'gidx': g, 'src': 'w3extend'} ownerg[g] = EXTID pooltake(best) addede += 1 changed = True # обновляем rtsd локально для следующего кадра rtsd[f] = (float(APPX[APROW[g]]), float(APPY[APROW[g]]), 0, 0) fhi = f if not changed: break print(f" Добавлено {addede} дет. | id{EXTID}: " f"f{min(recs[EXTID])}..{max(recs[EXTID])} ({len(recs[EXTID])} дет.)") taile = [recs[EXTID][f] for f in sorted(recs[EXTID]) if f >= 450] if taile: xs = [float(APPX[APROW[r['gidx']]]) for r in taile] ys = [float(APPY[APROW[r['gidx']]]) for r in tail_e] print(f" Конец: x {min(xs):.0f}..{max(xs):.0f}, y {min(ys):.0f}..{max(ys):.0f} | " f"уходит к лицевой выше вратарской (x>=90, y<=28): " f"{'ДА ✅' if max(xs) >= 90 and min(ys) <= 28 else 'частично'}")

=====================================================

6. Стоп-кран + пересборка + экспорт

=====================================================

for tid in [WID, CLEANID, EXTID]: rowst = [APROW[r['gidx']] for r in recs[tid].values() if r['gidx'] in APROW] c = votesof(rowst) tot = c.get(0, 0) + c.get(1, 0) if tot >= VOTEMIN and max(c.get(0, 0), c.get(1, 0)) / tot >= VOTEMAJ: basemeta[tid]['teamfta'] = 0 if c.get(0, 0) >= c.get(1, 0) else 1

assert len(recs) == 20, f"❌ I2" allg = [r['gidx'] for tid in recs for r in recs[tid].values()] assert len(allg) == len(set(allg)), "❌ I4" for tid in recs: ff = sorted(recs[tid]) assert len(ff) == len(set(ff)), f"❌ I4: tid={tid}" assert len(ff) >= MINTRACKDETS, f"❌ tid={tid} выродился" badpool = [g for g in allg if g in APROW and not bool(FIELDPOOL[APROW[g]])] assert not badpool, f"❌ I3: {badpool[:5]}" nt = Counter(basemeta[tid]['teamfta'] for tid in recs) assert nt.get(0, 0) == 10 and nt.get(1, 0) == 10, ( f"❌ СТОП-КРАН: составы t0={nt.get(0,0)}/t1={n_t.get(1,0)}. Экспорт НЕ выполнен.") print(f"\n🛑 СТОП-КРАН: 20 треков, 10/10, I3/I4 чисты")

def rebuild(tid): rowst = [APROW[r['gidx']] for r in recs[tid].values() if r['gidx'] in APROW] proto = meanE(rowst) fs = sorted(recs[tid]) det = {f: (float(APPX[APROW[recs[tid][f]['gidx']]]), float(APPY[APROW[recs[tid][f]['gidx']]])) for f in fs} rtsd = rtssmooth(det, fs[0], fs[-1]) gate = medmadgate([dgkof(r, proto) for r in rowst]) dgs, pes = [], [] for f in fs: row = APROW[recs[tid][f]['gidx']] dg = dgkof(row, proto) if dg is not None: dgs.append(dg) pes.append(math.hypot(float(APPX[row]) - rtsd[f][0], float(APPY[row]) - rtsd[f][1])) pur = float(np.mean((np.asarray(dgs) <= gate) & (np.asarray(pes[:len(dgs)]) <= POSGATEM))) if dgs else None parts = [] for f in fs: src = recs[tid][f]['src'] if parts and parts[-1]['src'] == src and f == parts[-1]['fidend'] + 1: parts[-1]['fidend'] = f else: parts.append({'fidstart': f, 'fidend': f, 'src': src}) framesout = [] for f in range(fs[0], fs[-1] + 1): sx, sy, vx, vy = rtsd[f] r = recs[tid].get(f) if r is not None: row = APROW[r['gidx']] framesout.append({'fid': f, 'gidx': r['gidx'], 'bbox': [round(float(APX1[row]), 1), round(float(APY1[row]), 1), round(float(APX2[row]), 1), round(float(APY2[row]), 1)], 'px': round(float(APPX[row]), 2), 'py': round(float(APPY[row]), 2), 'sx': round(sx, 2), 'sy': round(sy, 2), 'vx': round(vx, 2), 'vy': round(vy, 2), 'src': r['src']}) else: framesout.append({'fid': f, 'gidx': None, 'bbox': None, 'px': None, 'py': None, 'sx': round(sx, 2), 'sy': round(sy, 2), 'vx': round(vx, 2), 'vy': round(vy, 2), 'src': 'gap'}) return {'id': tid, 'team': TEAM12OFFTA.get(basemeta[tid]['teamfta'], 0), 'teamfta': basemeta[tid]['teamfta'], 'teamfixed': basemeta[tid]['teamfixed'], 'frames': framesout, 'protoe': (proto.tolist() if proto is not None else None), 'parts': parts, 'fidstart': fs[0], 'fidend': fs[-1], 'nframes': len(fs), 'stats': {'span': fs[-1] - fs[0] + 1, 'coverage': round(len(fs) / (fs[-1] - fs[0] + 1), 3), 'purityadaptive': (round(pur, 3) if pur is not None else None), 'col_gate': round(gate, 3)}}

changed = [WID, CLEANID, EXT_ID] for tid in changed: TR[tid] = rebuild(tid) print(f"🔁 Пересобраны: {changed}")

print(f"\n📋 Итог (изменённые):") for tid in changed: t = TR[tid] rowst = [APROW[r['gidx']] for r in recs[tid].values() if r['gidx'] in APROW] votes = votesof(rowst) span = t['fidend'] - t['fidstart'] + 1 print(f" id={tid:2d} ком.{t['team']} f{t['fidstart']}..{t['fidend']} " f"дет.{t['nframes']:3d} cov={t['nframes']/span:.2f} " f"purity={t['stats']['purityadaptive']} " f"голоса t0:{votes.get(0,0)}/t1:{votes.get(1,0)}")

tracksout = [] for tid in sorted(TR.keys()): t = TR[tid] rowst = [APROW[r['gidx']] for r in recs[tid].values() if r['gidx'] in APROW] votes = votesof(rowst) tracksout.append({ 'id': int(tid), 'team': int(t['team']), 'teamfta': int(t['teamfta']), 'teamfixed': bool(t.get('teamfixed', False)), 'teamvotes': {'1': int(votes.get(TL, 0)), '2': int(votes.get(TR, 0))}, 'fidstart': int(t['fidstart']), 'fidend': int(t['fidend']), 'nframes': int(t['nframes']), 'frames': t['frames'], 'protoe': t.get('protoe'), 'parts': t.get('parts', []), 'stats': t.get('stats', {})}) FIELDTRACKS = tracksout GIDXTOTRACK = {} for t in tracksout: for r in t['frames']: if r['gidx'] is not None: GIDXTOTRACK[int(r['gidx'])] = int(t['id']) ALLTRACKS = [dict(t, kind='field') for t in tracksout] for t in GKTRACKSFIN: gkteam12 = TEAM12OFFTA.get(SIDETEAM.get(t['side'])) ALLTRACKS.append({'id': f"GK{t['side']}", 'kind': 'gk', 'side': t['side'], 'team': gkteam12, 'teamfta': SIDETEAM.get(t['side']), 'fidstart': t['fidstart'], 'fidend': t['fidend'], 'nframes': t['nframes'], 'frames': t['frames']}) for fr in t['frames']: if fr.get('gidx') is not None: GIDXTOTRACK[int(fr['gidx'])] = f"GK{t['side']}" metaout = dict(BASE.get('meta', {})) metaout.update({'cell': CELLTAG, 'base': basecell, 's9': {'w1id7': {'added': addedw, 'tailf': WTAILF}, 'w2id15': {'removedfwd13': removed}, 'w3id20': {'added': addede}, 'poolleft': len(poolrows)}, 'teams': {'t0': 10, 't1': 10, 'ok1010': True}}) with open(FINPATH, 'w', encoding='utf-8') as f: json.dump({'meta': metaout, 'tracks': tracksout}, f, ensureascii=False, separators=(',', ':')) print(f"\n💾 {FINPATH}") fta2frames = {} for t in tracksout: for r in t['frames']: if r['gidx'] is None: continue row = APROW[r['gidx']] fta2frames.setdefault(r['fid'], []).append( {'gidx': r['gidx'], 'classid': int(APCLS[row]), 'team': int(t['teamfta']), 'trackid': int(t['id'])}) for t in GKTRACKSFIN: for fr in t['frames']: g = fr.get('gidx') if g is None or g not in APROW: continue fta2frames.setdefault(int(fr['fid']), []).append( {'gidx': int(g), 'classid': int(APCLS[APROW[int(g)]]), 'team': int(SIDETEAM.get(t['side'])), 'trackid': f"GK{t['side']}"}) fta2 = {'meta': {'cell': CELLTAG, 'source': 'fieldtracksfinal(v5.9) + gktracksfinal', 'teamscale': 'FTA (0/1)', 'teammapto12': {str(TL): 1, str(TR): 2}, 'nframes': len(fta2frames), 'nrecords': sum(len(v) for v in fta2frames.values())}, 'frames': {str(k): v for k, v in sorted(fta2frames.items())}} with open(FTA2PATH, 'w', encoding='utf-8') as f: json.dump(fta2, f, ensureascii=False, separators=(',', ':')) print(f"💾 {FTA2PATH} ({fta2['meta']['nrecords']} записей)") print(f"\n Глобали: FIELDTRACKS ({len(FIELDTRACKS)}), ALLTRACKS ({len(ALLTRACKS)}), " f"GIDXTOTRACK ({len(GIDXTOTRACK)}) | в пуле осталось {len(poolrows)}") gc.collect() print(f"\n✅ {CELLTAG} готов ({time.perfcounter() - t00:.1f} c). " "Финальная проверка: 32F v4 — TRACKIDSELECT=7 / 15 / 20.")

@title 32F v4. Визуализация полевых треков (ОФОРМЛЕНИЕ: крупный макет, тонкие линии,

id у старта, кружок старта меньше, конец = залитый квадратик)

#

v4 = 32F v3 + оформление режима "все треки" (и маркеры в режиме одного игрока):

[V1] Макет крупнее: S=8 -> 10 px/м (холст 1150x780), figsize (19, 14.5),

dpi сохранения 140.

[V2] Линии треков тоньше: lw=3 -> 1 (одиночный игрок: 2); точки детекций r=1.

[V3] id у НАЧАЛА трека (слева от стартовой точки, чёрная обводка; у края — справа);

конец подписи у последнего кадра убран.

[V4] Маркеры: старт = кружок r=4 (белое кольцо); конец = квадратик ~7x7 px со

сплошной заливкой цветом трека (+ белая окантовка 1 px). GK — те же маркеры,

подпись "GKL/GKR" у начала.

Прочее — v3: K1/K2 из треков (meta v9 без k1/k2), эталонная геометрия поля (ячейка 11),

TRACKIDSELECT=None -> общий макет + население; N -> игрок N (макет + X/Y/скорость +

6 контрольных кадров).

import os, gc, json, time, math import numpy as np import cv2 import matplotlib.pyplot as plt from collections import Counter

================== ПАРАМЕТРЫ (шапка) ==================

TRACKIDSELECT = 20 #None # None — все треки + график населения; int N — игрок N (1..20) GAPDRAW = 8 # кадров: разрыв линии траектории (ТЗ) NCTRLFRAMES = 6 # контрольных кадров в режиме одного игрока VISDIR = None # None -> output/debug_frames/cell32

t00 = time.perfcounter() CELLTAG = '32F v4' CACHEDIR = str(globals().get('CACHEDIR', '/content/cache')) OUTPUTDIR = str(globals().get('OUTPUTDIR', '/content/output')) FINPATH = os.path.join(OUTPUTDIR, 'fieldtracksfinal.json') GKFINPATH = os.path.join(OUTPUTDIR, 'gktracksfinal.json') APPATH = os.path.join(CACHEDIR, 'appearancecache.npz') VIDEOPATH = str(globals().get('VIDEOPATH', '/content/42ba340.mp4')) if VISDIR is None: VISDIR = os.path.join(OUTPUTDIR, 'debugframes', 'cell32') os.makedirs(VISDIR, existok=True)

TEAM1BGR = (255, 80, 80) # команда 1 (левые ворота) — синий в RGB TEAM2BGR = (0, 140, 255) # команда 2 — оранжевый в RGB GK_BGR = (128, 128, 128)

=====================================================

1. Данные (K1/K2 из треков; meta.k1/k2 — опционально)

=====================================================

assert os.path.exists(FINPATH), f"❌ {FINPATH} — выполните 31F." with open(FINPATH, encoding='utf-8') as f: FIN = json.load(f) TRACKS = FIN['tracks'] META = FIN.get('meta', {}) K1 = sum(1 for t in TRACKS if int(t.get('team', 0)) == 1) K2 = sum(1 for t in TRACKS if int(t.get('team', 0)) == 2) print(f"⚙️ {CELLTAG}: треков {len(TRACKS)} | K1(команда 1)={K1}, K2(команда 2)={K2} " f"(по факту из треков; meta.k1/k2 = " f"{META.get('k1', '—')}/{META.get('k2', '—')}) | TRACKIDSELECT={TRACKIDSELECT}") with open(GKFINPATH, encoding='utf-8') as f: GK = json.load(f)['tracks'] with np.load(APPATH) as z: APGIDX = z['gidx'].astype(np.int64) APX1 = z['x1'].astype(np.float32); APY1 = z['y1'].astype(np.float32) APX2 = z['x2'].astype(np.float32); APY2 = z['y2'].astype(np.float32) APROW = {int(g): i for i, g in enumerate(AP_GIDX.tolist())} FPS = float(META.get('fps', 25.0))

=====================================================

2. Геометрия поля (эталон из ячейки 11; при живой сессии — из globals)

=====================================================

if ('PITCHVERTICESM' in globals() and globals()['PITCHVERTICESM'] is not None and 'PITCHCONFIG' in globals() and globals()['PITCHCONFIG'] is not None): PITCHVERT = np.asarray(globals()['PITCHVERTICESM'], np.float32) PITCHEDGES = [(int(a), int(b)) for a, b in globals()['PITCHCONFIG'].edges] srcpitch = 'globals(ячейка 11)' else: PITCHVERT = np.array([ [0, 0], [0, 13.84], [0, 24.84], [0, 43.16], [0, 54.16], [0, 68], [5.5, 24.84], [5.5, 43.16], [11, 34], [16.5, 13.84], [16.5, 24.84], [16.5, 43.16], [16.5, 54.16], [52.5, 0], [52.5, 24.85], [52.5, 43.15], [52.5, 68], [88.5, 13.84], [88.5, 24.84], [88.5, 43.16], [88.5, 54.16], [94, 34], [99.5, 24.84], [99.5, 43.16], [105, 0], [105, 13.84], [105, 24.84], [105, 43.16], [105, 54.16], [105, 68], [43.35, 34], [61.65, 34]], dtype=np.float32) PITCHEDGES = [ (1, 2), (2, 3), (3, 4), (4, 5), (5, 6), (25, 26), (26, 27), (27, 28), (28, 29), (29, 30), (1, 14), (14, 25), (6, 17), (17, 30), (14, 17), (2, 10), (10, 11), (11, 12), (12, 13), (13, 5), (3, 7), (7, 8), (8, 4), (26, 18), (18, 19), (19, 20), (20, 21), (21, 29), (27, 23), (23, 24), (24, 28)] srcpitch = 'встроенная копия ячейки 11' print(f"📐 Макет поля: {srcpitch} | вершин {len(PITCHVERT)}, рёбер {len(PITCH_EDGES)}")

V1: масштаб 10 px/м — крупнее

S, M = 10, 5 MW, MH = int((105 + 2M)S), int((68 + 2M)S) def to_px(x, y): return int((x + M)S), int((y + M)S)

def drawpitch(img, lw=2): for (a, b) in PITCHEDGES: p1 = topx(PITCHVERT[a - 1][0], PITCHVERT[a - 1][1]) p2 = topx(PITCHVERT[b - 1][0], PITCHVERT[b - 1][1]) cv2.line(img, p1, p2, (230, 230, 230), lw, cv2.LINEAA) cv2.circle(img, topx(52.5, 34.0), int(round(9.15 * S)), (230, 230, 230), lw, cv2.LINEAA) cv2.circle(img, topx(52.5, 34.0), 3, (230, 230, 230), -1, cv2.LINE_AA)

CANVAS = np.zeros((MH, MW, 3), np.uint8) CANVAS[:] = (12, 62, 12) draw_pitch(CANVAS)

=====================================================

2b. Отрисовка трека: тонкая линия, старт-кружок r=4, конец-квадрат,

id у НАЧАЛА (V2/V3/V4)

=====================================================

def drawtrack(img, t, color, lw=1, pts=True, ptsr=1, label=True, gapbreak=True, startr=4, endhalf=3, fontscale=0.65, labeltext=None): frs = t['frames'] if not frs: return seg, segs = [], [] for r in frs: if seg and gapbreak and r['fid'] - seg[-1]['fid'] > GAPDRAW: segs.append(seg); seg = [] seg.append(r) if seg: segs.append(seg) for sg in segs: # V2: тонкая линия ptsnp = np.array([topx(r['sx'], r['sy']) for r in sg], np.int32) cv2.polylines(img, [ptsnp], False, color, lw, cv2.LINEAA) if pts: for r in frs: if r['gidx'] is not None: cv2.circle(img, topx(r['sx'], r['sy']), ptsr, color, -1, cv2.LINEAA) # V4: старт — маленький кружок (белое кольцо) s0 = frs[0] cx, cy = topx(s0['sx'], s0['sy']) cv2.circle(img, (cx, cy), startr, (255, 255, 255), 1, cv2.LINEAA) # V4: конец — квадратик со сплошной заливкой (цвет трека, белая окантовка) e0 = frs[-1] ex, ey = topx(e0['sx'], e0['sy']) cv2.rectangle(img, (ex - endhalf, ey - endhalf), (ex + endhalf, ey + endhalf), color, -1, cv2.LINEAA) cv2.rectangle(img, (ex - endhalf, ey - endhalf), (ex + endhalf, ey + endhalf), (255, 255, 255), 1, cv2.LINEAA) # V3: id у начала трека (слева от стартовой точки; у левого края — справа) if label: lab = str(labeltext if labeltext is not None else t.get('id', '?')) (tw, th), = cv2.getTextSize(lab, cv2.FONTHERSHEYSIMPLEX, fontscale, 2) tx = cx - tw - 8 if tx < 2: tx = cx + startr + 4 ty = cy + th // 2 + 1 cv2.putText(img, lab, (tx, ty), cv2.FONTHERSHEYSIMPLEX, fontscale, (0, 0, 0), 3, cv2.LINEAA) cv2.putText(img, lab, (tx, ty), cv2.FONTHERSHEYSIMPLEX, fontscale, (255, 255, 255), 1, cv2.LINE_AA)

=====================================================

3. Режим A: все треки + график населения (V1: крупная фигура)

=====================================================

if TRACKIDSELECT is None: img = CANVAS.copy() for t in TRACKS: col = TEAM1BGR if t['team'] == 1 else TEAM2BGR drawtrack(img, t, col, lw=1, pts=True, ptsr=1, label=True) for g in GK: col = TEAM1BGR if g.get('team') == 1 else TEAM2BGR if g.get('team') == 2 else GKBGR frs = g['frames'] if not frs: continue ptsnp = np.array([topx(r['sx'], r['sy']) for r in frs], np.int32) cv2.polylines(img, [ptsnp], False, col, 1, cv2.LINEAA) # старт/конец/подпись — как у полевых (подпись у начала) s0 = frs[0] cx, cy = topx(s0['sx'], s0['sy']) cv2.circle(img, (cx, cy), 4, (255, 255, 255), 1, cv2.LINEAA) e0 = frs[-1] ex, ey = topx(e0['sx'], e0['sy']) cv2.rectangle(img, (ex - 3, ey - 3), (ex + 3, ey + 3), col, -1, cv2.LINEAA) cv2.rectangle(img, (ex - 3, ey - 3), (ex + 3, ey + 3), (255, 255, 255), 1, cv2.LINEAA) lab = f"GK{g['side']}" (tw, th), = cv2.getTextSize(lab, cv2.FONTHERSHEYSIMPLEX, 0.6, 2) tx = cx - tw - 8 if tx < 2: tx = cx + 8 cv2.putText(img, lab, (tx, cy + th // 2 + 1), cv2.FONTHERSHEYSIMPLEX, 0.6, (0, 0, 0), 3, cv2.LINEAA) cv2.putText(img, lab, (tx, cy + th // 2 + 1), cv2.FONTHERSHEYSIMPLEX, 0.6, (255, 255, 255), 1, cv2.LINEAA)

fig, axes = plt.subplots(2, 1, figsize=(19, 14.5), gridspeckw={'heightratios': [2.7, 1.0]}) axes[0].imshow(cv2.cvtColor(img, cv2.COLORBGR2RGB)) axes[0].settitle(f"32F: треки полевых (RTS) | синяя = команда 1 (левые ворота, x{K1}) | " f"оранжевая = команда 2 (x{K2}) | тонкие серые = GK | " f"id у старта (кружок = начало, квадратик = конец) | " f"разрыв при gap>{GAP_DRAW}") axes[0].axis('off')

fidsall = sorted({r['fid'] for t in TRACKS for r in t['frames']}) n1 = np.zeros(len(fidsall), int); n2 = np.zeros(len(fidsall), int) fi = {f: i for i, f in enumerate(fidsall)} for t in TRACKS: arr = n1 if t['team'] == 1 else n2 for r in t['frames']: if r['gidx'] is not None: arr[fi[r['fid']]] += 1 axes[1].plot(fidsall, n1, color='b', lw=1.2, label=f'команда 1 (активные)') axes[1].plot(fidsall, n2, color='orange', lw=1.2, label=f'команда 2 (активные)') axes[1].axhline(K1, color='b', ls='--', lw=1, label=f'K1={K1}') axes[1].axhline(K2, color='orange', ls='--', lw=1, label=f'K2={K2}') axes[1].setxlabel('кадр'); axes[1].setylabel('активных треков') axes[1].setylim(0, 12) axes[1].legend(loc='lower right', fontsize=8, ncol=2) axes[1].settitle('Население по командам (с детекцией) против K (пунктир); ' 'провалы = потери детекции/окклюзии (трек жив, позиция RTS остаётся)') axes[1].grid(alpha=0.3) plt.tightlayout() pall = os.path.join(VISDIR, 'alltracks.png') plt.savefig(pall, dpi=140, bboxinches='tight') plt.show() print(f"🖼️ {pall}") print(" Для проверки отдельного игрока: TRACKID_SELECT = 1..20.")

=====================================================

4. Режим B: один игрок (маркеры v4, линия толще для проверки)

=====================================================

else: tidsel = int(TRACKIDSELECT) T = next((t for t in TRACKS if int(t['id']) == tidsel), None) assert T is not None, f"❌ id={tidsel} нет в треках" col = TEAM1BGR if T['team'] == 1 else TEAM2BGR frs = T['frames'] f0, f1 = T['fidstart'], T['fidend'] print(f"🎯 Игрок id={tidsel} (команда {T['team']}): f{f0}..{f1}, " f"детекций {T['nframes']}/{f1-f0+1} ({100*T['nframes']/(f1-f0+1):.0f}%), " f"частей {len(T.get('parts', []))}")

img = CANVAS.copy() drawtrack(img, T, col, lw=2, pts=True, ptsr=2, label=False) for p in T.get('parts', []): a = next((r for r in frs if r['fid'] == p['fidstart']), None) if a is not None and p['src'] in ('recovered', 'tailfix', 'holefixa', 'tailfixa', 'startfixb', 'restoredext'): cv2.circle(img, topx(a['sx'], a['sy']), 6, (0, 255, 255), 2, cv2.LINEAA) for c in T.get('changepoints', []): r = next((r for r in frs if r['fid'] == c['fidcut']), None) if r is not None: cv2.drawMarker(img, topx(r['sx'], r['sy']), (0, 0, 255), cv2.MARKER_CROSS, 16, 3)

fidst = [r['fid'] for r in frs] xs = np.array([r['sx'] for r in frs]) ys = np.array([r['sy'] for r in frs]) vxs = np.array([r['vx'] for r in frs]) vys = np.array([r['vy'] for r in frs]) spd = np.hypot(vxs, vys) hasdet = np.array([r['gidx'] is not None for r in frs])

fig = plt.figure(figsize=(16, 12)) gs = fig.addgridspec(3, 2, heightratios=[2.2, 1, 1], hspace=0.35, wspace=0.18) ax0 = fig.addsubplot(gs[0, :]) ax0.imshow(cv2.cvtColor(img, cv2.COLORBGR2RGB)) ax0.settitle(f"Игрок id={tidsel} (команда {T['team']}) | линия = RTS | точки = " f"детекции | кружок = начало, квадратик = конец | жёлтый круг = " f"восстановленные части | разрыв при gap>{GAPDRAW}") ax0.axis('off') ax1 = fig.addsubplot(gs[1, 0]) for p in T.get('parts', []): ax1.axvspan(p['fidstart'], p['fidend'], color={'core': '#aaffaa', 'arb': '#aaaaff', 'recovered': '#ffffaa', 'restitch': '#ffaaaa', 'v5': '#eeeeee', 'tailfix': '#ffffaa', 'tailfixa': '#ffffaa', 'holefixa': '#ffffaa', 'startfixb': '#aaffaa', 'reclaima': '#ffccaa', 'reclaimb': '#ffccaa', 'restored': '#ccffcc', 'restoredext': '#ccffcc'}.get(p['src'], '#dddddd'), alpha=0.35) ax1.plot(fidst, xs, 'b-', lw=1) ax1.plot(np.array(fidst)[hasdet], xs[hasdet], 'k.', ms=1.5) ax1.axhline(52.5, color='gray', ls=':', lw=1) ax1.setylabel('X, м'); ax1.setxlabel('кадр') ax1.settitle('X(t); полосы = части') ax1.grid(alpha=0.3) ax2 = fig.addsubplot(gs[1, 1]) for p in T.get('parts', []): ax2.axvspan(p['fidstart'], p['fidend'], color={'core': '#aaffaa', 'arb': '#aaaaff', 'recovered': '#ffffaa', 'restitch': '#ffaaaa', 'v5': '#eeeeee', 'tailfix': '#ffffaa', 'tailfixa': '#ffffaa', 'holefixa': '#ffffaa', 'startfixb': '#aaffaa', 'reclaima': '#ffccaa', 'reclaimb': '#ffccaa', 'restored': '#ccffcc', 'restoredext': '#ccffcc'}.get(p['src'], '#dddddd'), alpha=0.35) ax2.plot(fidst, ys, 'g-', lw=1) ax2.plot(np.array(fidst)[hasdet], ys[hasdet], 'k.', ms=1.5) ax2.axhline(34.0, color='gray', ls=':', lw=1) ax2.setylabel('Y, м'); ax2.setxlabel('кадр') ax2.settitle('Y(t)') ax2.grid(alpha=0.3) ax3 = fig.addsubplot(gs[2, :]) ax3.plot(fidst, spd * FPS, 'r-', lw=1) ax3.axhline(7.0, color='gray', ls='--', lw=1) ax3.setylabel('|v|, м/с'); ax3.setxlabel('кадр') ax3.settitle('Скорость RTS (м/с); пунктир = 7 м/с (спринт)') ax3.grid(alpha=0.3) plt.suptitle(f"32F: игрок id={tidsel} | команда {T['team']} | " f"f{f0}..{f1} | детекций {T['nframes']}", fontsize=13) pone = os.path.join(VISDIR, f'track{tidsel:02d}.png') plt.savefig(pone, dpi=140, bboxinches='tight') plt.show() print(f"🖼️ {p_one}")

detfids = [r['fid'] for r in frs if r['gidx'] is not None] sel = [] if detfids: idxs = np.linspace(0, len(detfids) - 1, min(NCTRLFRAMES, len(detfids))).astype(int) sel = sorted(set(detfids[i] for i in idxs))[:NCTRLFRAMES] if sel and os.path.exists(VIDEOPATH): cap = cv2.VideoCapture(VIDEOPATH) framesbyid = {} for fidt in sel: cap.set(cv2.CAPPROPPOSFRAMES, int(fidt)) ret, fr = cap.read() if ret: framesbyid[int(fidt)] = fr cap.release() if framesbyid: ncols = len(framesbyid) fig, axes = plt.subplots(2, ncols, figsize=(4.6*ncols, 8.6)) axes = np.array(axes).reshape(2, ncols) if ncols > 1 else np.array([[axes[0]], [axes[1]]]) for ci, fidt in enumerate(sorted(framesbyid)): vis = framesbyid[fidt].copy() r = next((q for q in frs if q['fid'] == fidt and q['gidx'] is not None), None) if r is not None and r['bbox'] is not None: x1, y1, x2, y2 = [int(v) for v in r['bbox']] cv2.rectangle(vis, (x1, y1), (x2, y2), col, 4) cv2.putText(vis, f"id{tidsel}", (x1, max(14, y1 - 8)), cv2.FONTHERSHEYSIMPLEX, 0.8, (0, 0, 0), 3, cv2.LINEAA) cv2.putText(vis, f"id{tidsel}", (x1, max(14, y1 - 8)), cv2.FONTHERSHEYSIMPLEX, 0.8, col, 1, cv2.LINEAA) cv2.rectangle(vis, (0, 0), (vis.shape[1], 34), (0, 0, 0), -1) cv2.putText(vis, f"frame {fidt}", (10, 24), cv2.FONTHERSHEYSIMPLEX, 0.8, (255, 255, 255), 2, cv2.LINEAA) axes[0, ci].imshow(cv2.cvtColor(vis, cv2.COLORBGR2RGB)) axes[0, ci].settitle(f"f{fidt}", fontsize=10) axes[0, ci].axis('off') mini = CANVAS.copy() rr = next((q for q in frs if q['fid'] == fidt), None) if rr is not None: c = topx(rr['sx'], rr['sy']) cv2.circle(mini, c, 12, (0, 0, 255), 3, cv2.LINEAA) axes[1, ci].imshow(cv2.cvtColor(mini, cv2.COLORBGR2RGB)) axes[1, ci].axis('off') plt.suptitle(f"32F: контрольные кадры игрока id={tidsel} " f"(команда {T['team']})", fontsize=13) plt.tightlayout() pctrl = os.path.join(VISDIR, f'track{tidsel:02d}ctrl.jpg') plt.savefig(pctrl, dpi=110, bboxinches='tight') plt.show() print(f"🖼️ {pctrl}") else: print("⚠️ Кадры видео не прочитаны") else: print("⚠️ Видео недоступно или нет детекций — контрольные кадры пропущены")

gc.collect() print(f"\n✅ {CELLTAG} готов ({time.perfcounter()-t00:.1f} c). " "Итеративно: меняйте TRACKIDSELECT (1..20) и перезапускайте.")