VIDEO-005: add full offline QPSK IQ video receiver

This commit is contained in:
LittleSam129
2026-09-17 13:34:58 +03:00
parent 95f664076a
commit 6df5f8aa39
18 changed files with 2651 additions and 13 deletions

106
protocol/video005_timing.py Normal file
View File

@@ -0,0 +1,106 @@
"""Поиск преамбулы и восстановление дробного момента выборки из IQ."""
from __future__ import annotations
from dataclasses import dataclass
import math
import numpy as np
from scipy.signal import fftconvolve, find_peaks
from protocol.video003_modem import PREAMBLE_SYMBOLS, bpsk_symbols, known_preamble_bits
@dataclass(frozen=True)
class PreambleCandidate:
start_sample: float
score: float
def search_preamble_grid(
matched: np.ndarray,
samples_per_symbol: int,
minimum_score: float = 0.5,
) -> tuple[PreambleCandidate, ...]:
"""Ищет пилот во всём сигнале, не получая границ кадров от передатчика."""
if matched.ndim != 1 or samples_per_symbol < 2 or not 0 < minimum_score < 1:
raise ValueError("неверные параметры поиска преамбулы")
known = bpsk_symbols(known_preamble_bits())
all_peaks: list[PreambleCandidate] = []
# Две тактовые фазы дают грубый поиск; дробный пик уточняется отдельно.
phase_step = max(1, samples_per_symbol // 2)
for phase in range(0, samples_per_symbol, phase_step):
series = matched[phase::samples_per_symbol]
if series.size < PREAMBLE_SYMBOLS:
continue
correlation = fftconvolve(series, known[::-1].conj(), mode="valid")
power = np.abs(series) ** 2
running = np.concatenate(([0.0], np.cumsum(power, dtype=np.float64)))
window_power = running[PREAMBLE_SYMBOLS:] - running[:-PREAMBLE_SYMBOLS]
score = np.abs(correlation) / np.sqrt(
np.maximum(window_power * PREAMBLE_SYMBOLS, 1e-18)
)
peaks, _ = find_peaks(
score, height=minimum_score, distance=PREAMBLE_SYMBOLS // 2
)
for index in peaks:
all_peaks.append(PreambleCandidate(float(phase + index * samples_per_symbol),
float(score[index])))
all_peaks.sort(key=lambda item: item.start_sample)
merged: list[PreambleCandidate] = []
for candidate in all_peaks:
if merged and candidate.start_sample - merged[-1].start_sample < 2 * samples_per_symbol:
if candidate.score > merged[-1].score:
merged[-1] = candidate
else:
merged.append(candidate)
return tuple(merged)
def refine_timing(
matched: np.ndarray,
candidate: PreambleCandidate,
samples_per_symbol: int,
) -> PreambleCandidate:
"""Уточняет пик корреляции до доли отсчёта по известной преамбуле."""
known = bpsk_symbols(known_preamble_bits())
center = int(candidate.start_sample)
half_phase_step = max(2, samples_per_symbol // 4)
if (center < half_phase_step + 1
or center + (PREAMBLE_SYMBOLS - 1) * samples_per_symbol
+ half_phase_step + 1 >= matched.size):
return candidate
indexes = center + np.arange(PREAMBLE_SYMBOLS) * samples_per_symbol
shifts = range(-half_phase_step - 1, half_phase_step + 2)
scores: list[float] = [float(abs(np.vdot(known, matched[indexes + shift])))
for shift in shifts]
best = int(np.argmax(scores))
if best in (0, len(scores) - 1):
fractional = 0.0
else:
curvature = scores[best - 1] - 2 * scores[best] + scores[best + 1]
fractional = (0.0 if curvature >= -1e-12 else
0.5 * (scores[best - 1] - scores[best + 1]) / curvature)
fractional = float(np.clip(fractional, -0.5, 0.5))
return PreambleCandidate(candidate.start_sample + shifts[best] + fractional,
candidate.score)
def sample_symbols(
matched: np.ndarray,
start_sample: float,
samples_per_symbol: int,
maximum_symbols: int,
) -> np.ndarray:
"""Интерполирует найденные такты без входного oracle timing."""
if not math.isfinite(start_sample) or start_sample < 0 or maximum_symbols < 1:
raise ValueError("неверные границы выборки символов")
available = int((matched.size - 2 - start_sample) // samples_per_symbol) + 1
count = min(maximum_symbols, max(0, available))
if count == 0:
return np.empty(0, dtype=np.complex64)
positions = start_sample + np.arange(count, dtype=np.float64) * samples_per_symbol
lower = np.floor(positions).astype(np.int64)
fraction = positions - lower
return (matched[lower] * (1 - fraction)
+ matched[lower + 1] * fraction).astype(np.complex64)