Lab047: transmit JPEG over real RF link

This commit is contained in:
LittleSam129
2026-09-03 13:13:23 +03:00
parent b0111d2232
commit 6fb7e4ec54
11 changed files with 2858 additions and 17 deletions

View File

@@ -54,8 +54,14 @@ BACKGROUND_1400MHZ_LOCAL_PEAK_QUANTILE = 0.999
BACKGROUND_1400MHZ_MAD_MULTIPLIER = 4.0
FIXED_1400MHZ_CANDIDATE_THRESHOLD = 0.03361873421818018
DETECTION_CHUNK_SAMPLES = 16_000_000
MAX_CANDIDATES = 10_000
MAX_SEARCH_ITERATIONS = 10_000
# В потоковом проходе это предел реально выполненных строгих проверок, а не
# размер промежуточного списка. Он не зависит от ожидаемого числа кадров.
MAX_CANDIDATES = 100_000
MAX_STORED_COARSE_CANDIDATES = 10_000
MAX_STORED_OUTCOMES = 4_096
MAX_STORED_CURSOR_POSITIONS = MAX_STORED_OUTCOMES + 1
COARSE_CANDIDATE_MEMORY_BYTES = 108
MAX_SEARCH_ITERATIONS = MAX_CANDIDATES
MAX_VALID_FRAMES = 256
REFINEMENT_MARGIN_SAMPLES = 4_000
# resample_poly/fftconvolve одновременно создают несколько крупных временных
@@ -143,6 +149,12 @@ class CoarseSearchResult:
elapsed_seconds: float
termination_reason: str
max_candidates_reached: bool
candidate_count: int = 0
candidates_skipped_as_same_local_event: int = 0
exact_duplicate_candidates_skipped: int = 0
blocks_processed: int = 0
processed_until_sample: int = 0
candidate_memory_bytes_peak: int = 0
@dataclass(frozen=True)
@@ -197,6 +209,8 @@ class StrictSearchResult:
termination_reason: str
max_search_iterations_reached: bool
max_valid_frames_reached: bool
outcomes_truncated: bool = False
cursor_history_truncated: bool = False
@dataclass(frozen=True)
@@ -524,7 +538,7 @@ def coarse_search_capture(
capture_path: Path,
rule: CandidateRule,
*,
max_candidates: int = MAX_CANDIDATES,
max_candidates: int = MAX_STORED_COARSE_CANDIDATES,
chunk_samples: int = DETECTION_CHUNK_SAMPLES,
) -> CoarseSearchResult:
"""Найти first-stage candidates во всём capture без TX offsets/count."""
@@ -538,6 +552,8 @@ def coarse_search_capture(
total_local_peaks = 0
maximum_window = 0
maximum_temporary = 0
blocks_processed = 0
processed_until_sample = 0
termination = "END_OF_CAPTURE"
max_reached = False
decimation = lab046c.lab044.lab043.DETECTION_DECIMATION
@@ -546,6 +562,11 @@ def coarse_search_capture(
whole,
chunk_samples=chunk_samples,
):
blocks_processed += 1
processed_until_sample = min(
sample_count,
window_start + int(scores.size) * decimation,
)
total_local_peaks += int(indexes.size)
maximum_window = max(maximum_window, window_samples)
maximum_temporary = max(maximum_temporary, temporary_bytes)
@@ -571,6 +592,10 @@ def coarse_search_capture(
elapsed_seconds=time.perf_counter() - started,
termination_reason=termination,
max_candidates_reached=max_reached,
candidate_count=len(candidates),
blocks_processed=blocks_processed,
processed_until_sample=processed_until_sample,
candidate_memory_bytes_peak=len(candidates) * COARSE_CANDIDATE_MEMORY_BYTES,
)
@@ -872,6 +897,216 @@ def strict_process_candidates(
)
def stream_process_capture(
capture_path: Path,
rule: CandidateRule,
*,
max_candidates: int = MAX_CANDIDATES,
max_valid_frames: int = MAX_VALID_FRAMES,
chunk_samples: int = DETECTION_CHUNK_SAMPLES,
decoder: CandidateDecoder = decode_candidate_from_memmap,
) -> tuple[CoarseSearchResult, StrictSearchResult]:
"""Последовательно находить и сразу строго проверять кандидатов.
``max_candidates`` ограничивает число вызовов строгого декодера за весь
capture. Кандидаты всего файла не накапливаются. После CRC PASS курсор
владеет областью до конца принятого кадра; после отказа пропускается только
текущая позиция. Точный повтор sample offset подавляется без радиуса,
который мог бы скрыть соседний настоящий кадр.
"""
if max_candidates < 0 or max_valid_frames < 0:
raise ValueError("Safety limits не могут быть отрицательными")
raw, sample_count = _validate_capture(capture_path)
whole = SampleInterval("FULL_CAPTURE", 0, sample_count)
started = time.perf_counter()
decimation = lab046c.lab044.lab043.DETECTION_DECIMATION
total_local_peaks = 0
first_stage_candidates = 0
candidates_skipped_as_same_local_event = 0
exact_duplicate_candidates_skipped = 0
blocks_processed = 0
processed_until_sample = 0
maximum_window = 0
maximum_temporary = 0
candidate_memory_peak = 0
last_candidate_offset: int | None = None
cursor = 0
cursor_history = [0]
cursor_history_truncated = False
stored_outcomes: list[CandidateOutcome] = []
outcomes_truncated = False
accepted_frames: list[lab046b.IqFrameReception] = []
strict_elapsed = 0.0
maximum_strict_window = 0
counters = {
"candidates_processed": 0,
"rejected_during_refinement": 0,
"refinement_pass": 0,
"timing_pass": 0,
"carrier_pass": 0,
"phy_header_pass": 0,
"phy_header_fail": 0,
"fec_attempts": 0,
"fec_pass": 0,
"fec_fail": 0,
"protocol_parse_pass": 0,
"crc_pass": 0,
"crc_fail": 0,
"valid_frames": 0,
}
termination = "END_OF_CAPTURE"
max_candidates_reached = False
max_valid_frames_reached = False
stop = False
for window_start, scores, indexes, window_samples, temporary_bytes in _score_blocks(
raw,
whole,
chunk_samples=chunk_samples,
):
blocks_processed += 1
processed_until_sample = min(
sample_count,
window_start + int(scores.size) * decimation,
)
total_local_peaks += int(indexes.size)
maximum_window = max(maximum_window, window_samples)
selected = indexes[scores[indexes] >= np.float32(rule.threshold)]
maximum_temporary = max(
maximum_temporary,
temporary_bytes + int(selected.nbytes) + COARSE_CANDIDATE_MEMORY_BYTES,
)
candidate_memory_peak = max(
candidate_memory_peak,
int(selected.nbytes) + COARSE_CANDIDATE_MEMORY_BYTES,
)
for index in selected:
sample_offset = window_start + int(index) * decimation
if last_candidate_offset is not None:
if sample_offset < last_candidate_offset:
raise RuntimeError("Кандидаты потокового поиска нарушили порядок offset")
if sample_offset == last_candidate_offset:
exact_duplicate_candidates_skipped += 1
continue
last_candidate_offset = sample_offset
first_stage_candidates += 1
if sample_offset < cursor:
candidates_skipped_as_same_local_event += 1
continue
if counters["candidates_processed"] >= max_candidates:
termination = "MAX_CANDIDATES_REACHED"
max_candidates_reached = True
stop = True
break
if len(accepted_frames) >= max_valid_frames:
termination = "MAX_VALID_FRAMES_REACHED"
max_valid_frames_reached = True
stop = True
break
candidate = CoarseCandidate(
sample_offset=sample_offset,
score=float(scores[index]),
)
outcome = decoder(
raw,
sample_count,
candidate,
counters["candidates_processed"] + 1,
)
counters["candidates_processed"] += 1
counters["rejected_during_refinement"] += int(not outcome.refinement_pass)
counters["refinement_pass"] += int(outcome.refinement_pass)
counters["timing_pass"] += int(outcome.timing_pass)
counters["carrier_pass"] += int(outcome.carrier_pass)
counters["phy_header_pass"] += int(outcome.phy_header_pass)
counters["phy_header_fail"] += int(
outcome.phy_header_attempted and not outcome.phy_header_pass
)
counters["fec_attempts"] += int(outcome.fec_attempted)
counters["fec_pass"] += int(outcome.fec_pass)
counters["fec_fail"] += int(outcome.fec_attempted and not outcome.fec_pass)
counters["protocol_parse_pass"] += int(outcome.protocol_parse_pass)
counters["crc_pass"] += int(outcome.crc == lab046c.lab045.CRC_PASS)
counters["crc_fail"] += int(outcome.crc == lab046c.lab045.CRC_FAIL)
strict_elapsed += outcome.elapsed_seconds
maximum_strict_window = max(
maximum_strict_window,
outcome.maximum_local_window_samples,
)
accepted = bool(
outcome.accepted
and outcome.frame is not None
and outcome.crc == lab046c.lab045.CRC_PASS
)
if accepted:
accepted_frames.append(outcome.frame)
counters["valid_frames"] += 1
if len(stored_outcomes) < MAX_STORED_OUTCOMES or accepted:
stored_outcomes.append(outcome)
else:
outcomes_truncated = True
next_cursor = int(outcome.next_sample) if accepted else sample_offset + 1
cursor = max(cursor + 1, next_cursor)
if len(cursor_history) < MAX_STORED_CURSOR_POSITIONS:
cursor_history.append(cursor)
else:
cursor_history_truncated = True
if stop:
break
# Если генератор блоков исчерпан без защитной остановки, обработан весь
# интервал. Последний допустимый старт корреляции закономерно раньше конца
# IQ на длину опорной последовательности и не является границей покрытия.
if not stop:
processed_until_sample = sample_count
total_elapsed = time.perf_counter() - started
coarse = CoarseSearchResult(
sample_count=sample_count,
total_local_peaks=total_local_peaks,
candidates=(),
maximum_window_samples=maximum_window,
estimated_maximum_temporary_bytes=maximum_temporary,
elapsed_seconds=max(0.0, total_elapsed - strict_elapsed),
termination_reason=termination,
max_candidates_reached=max_candidates_reached,
candidate_count=first_stage_candidates,
candidates_skipped_as_same_local_event=candidates_skipped_as_same_local_event,
exact_duplicate_candidates_skipped=exact_duplicate_candidates_skipped,
blocks_processed=blocks_processed,
processed_until_sample=processed_until_sample,
candidate_memory_bytes_peak=candidate_memory_peak,
)
strict = StrictSearchResult(
counters=StrictCounters(**counters),
accepted_frames=tuple(accepted_frames),
outcomes=tuple(stored_outcomes),
cursor_history=tuple(cursor_history),
elapsed_seconds=strict_elapsed,
average_candidate_seconds=(
strict_elapsed / counters["candidates_processed"]
if counters["candidates_processed"]
else 0.0
),
maximum_local_window_samples=maximum_strict_window,
maximum_local_window_bytes=(
maximum_strict_window * np.dtype(np.complex64).itemsize
),
termination_reason=termination,
max_search_iterations_reached=max_candidates_reached,
max_valid_frames_reached=max_valid_frames_reached,
outcomes_truncated=outcomes_truncated,
cursor_history_truncated=cursor_history_truncated,
)
return coarse, strict
def assemble_independent_object(
frames: tuple[lab046b.IqFrameReception, ...],
) -> ObjectVerdict:
@@ -914,24 +1149,15 @@ def _run_with_fixed_candidate_rule(
capture_path = Path(capture_path)
_, sample_count = _validate_capture(capture_path)
coarse = coarse_search_capture(capture_path, rule)
if coarse.max_candidates_reached:
strict = strict_process_candidates(
capture_path,
(),
max_search_iterations=0,
max_valid_frames=0,
)
verdict = assemble_independent_object(())
else:
strict = strict_process_candidates(capture_path, coarse.candidates)
verdict = assemble_independent_object(strict.accepted_frames)
coarse, strict = stream_process_capture(capture_path, rule)
verdict = assemble_independent_object(strict.accepted_frames)
elapsed = time.perf_counter() - full_started
completed = bool(
not coarse.max_candidates_reached
and not strict.max_search_iterations_reached
and not strict.max_valid_frames_reached
and strict.termination_reason == "END_OF_CANDIDATES"
and strict.termination_reason == "END_OF_CAPTURE"
and coarse.processed_until_sample == sample_count
)
return IndependentRecoveryResult(
completed=completed,
@@ -1212,6 +1438,8 @@ def _independent_summary(result: IndependentRecoveryResult) -> dict:
"termination_reason": result.strict.termination_reason,
"max_search_iterations_reached": result.strict.max_search_iterations_reached,
"max_valid_frames_reached": result.strict.max_valid_frames_reached,
"outcomes_truncated": result.strict.outcomes_truncated,
"cursor_history_truncated": result.strict.cursor_history_truncated,
"outcomes": [
{
key: value