Compare commits
6 Commits
repair/pro
...
04ce66fdfe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04ce66fdfe | ||
|
|
78258b5355 | ||
|
|
6d49036066 | ||
|
|
557b082f2b | ||
|
|
f3a11b29b3 | ||
|
|
35cb25fccc |
@@ -33,3 +33,24 @@
|
||||
|
||||
```text
|
||||
git version 2.51.1.windows.1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Запись 002
|
||||
|
||||
## Дата
|
||||
|
||||
27 июля 2026 года
|
||||
|
||||
## Тема
|
||||
|
||||
Завершение Lab028: пакетирование синхронного BASE + ROI.
|
||||
|
||||
## Выполнено
|
||||
|
||||
- Реализован фиксированный бинарный заголовок размером 32 байта.
|
||||
- Добавлены packet CRC32 для заголовка и payload и object CRC32 для полного JPEG.
|
||||
- Исследованы размеры payload 64, 128, 256, 512 и 1024 байта.
|
||||
- Размер payload 512 байт принят как рабочий кандидат.
|
||||
- Окончательный выбор размера пакета перенесён в Lab029.
|
||||
|
||||
BIN
data/processed/lab025/lab025_audio_spectrum.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
data/processed/lab025/lab025_audio_waveform.png
Normal file
|
After Width: | Height: | Size: 237 KiB |
29
data/processed/lab025/lab025_report.txt
Normal file
@@ -0,0 +1,29 @@
|
||||
Lab025. Приём и программная WFM-демодуляция
|
||||
|
||||
URI Pluto+: ip:192.168.2.1
|
||||
Частота станции: 100100000 Гц
|
||||
RX LO: 100350000 Гц
|
||||
Цифровое смещение: +250000 Гц
|
||||
Частота дискретизации RX: 2400000 Гц
|
||||
Полоса RX: 1500000 Гц
|
||||
Количество рабочих буферов: 128
|
||||
Количество IQ-сэмплов: 8388608
|
||||
Длительность IQ-записи: 3.495253 с
|
||||
Частота канального сигнала после децимации: 240000 Гц
|
||||
Длительность итогового WAV: 3.445250 с
|
||||
Частота WAV: 48000 Гц
|
||||
Тип PCM: signed PCM16, mono
|
||||
RMS итогового аудио: 0.389148
|
||||
Пиковая амплитуда: 0.974792
|
||||
Отсчёты на границе PCM16: 0.000000 %
|
||||
|
||||
Выходные файлы:
|
||||
WAV: data\processed\lab025\lab025_wfm_audio_100_1mhz.wav
|
||||
Радиоспектр: data\processed\lab025\lab025_rf_spectrum.png
|
||||
Звуковая волна: data\processed\lab025\lab025_audio_waveform.png
|
||||
Спектр звука: data\processed\lab025\lab025_audio_spectrum.png
|
||||
Отчёт: data\processed\lab025\lab025_report.txt
|
||||
|
||||
Использован только приёмный канал RX1.
|
||||
Передатчики TX1 и TX2 не использовались.
|
||||
Необработанные IQ-сэмплы на диск не сохранялись.
|
||||
BIN
data/processed/lab025/lab025_rf_spectrum.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
data/processed/lab025/lab025_wfm_audio_100_1mhz.wav
Normal file
BIN
data/processed/lab026/lab026_bitrate_vs_fps.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
data/processed/lab026/lab026_bitrate_vs_jpeg_quality.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
data/processed/lab026/lab026_bitrate_vs_resolution.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
data/processed/lab026/lab026_candidate_profiles.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
33
data/processed/lab026/lab026_profiles.csv
Normal file
@@ -0,0 +1,33 @@
|
||||
experiment,profile_name,width,height,target_fps,actual_fps,color_mode,jpeg_quality,source_duration_s,selected_frames,total_bytes,mean_frame_bytes,median_frame_bytes,p95_frame_bytes,max_frame_bytes,payload_bitrate_bps,payload_bitrate_kbps,source_file_bitrate_kbps,reduction_ratio,percent_of_source,mean_psnr_db
|
||||
fps_sweep,fps_30,640,360,30.000000,30.000000,grayscale,50,20.766667,623,10219569,16403.803,16282.000,18471.500,21081,3936912.616,3936.912616,3001.342536,0.762359,131.171720,37.349661
|
||||
fps_sweep,fps_10,640,360,10.000000,10.016051,grayscale,50,20.766667,208,3641634,17507.856,17509.500,18958.400,21081,1402876.661,1402.876661,3001.342536,2.139420,46.741638,36.369592
|
||||
fps_sweep,fps_5,640,360,5.000000,5.008026,grayscale,50,20.766667,104,1822387,17522.952,17547.000,18910.400,21081,702043.146,702.043146,3001.342536,4.275154,23.390970,36.352519
|
||||
fps_sweep,fps_2,640,360,2.000000,2.022472,grayscale,50,20.766667,42,737435,17557.976,17476.500,18926.800,21081,284084.109,284.084109,3001.342536,10.564979,9.465234,36.319782
|
||||
fps_sweep,fps_1,640,360,1.000000,1.011236,grayscale,50,20.766667,21,369317,17586.524,17483.000,18935.000,21081,142273.002,142.273002,3001.342536,21.095658,4.740312,36.287711
|
||||
fps_sweep,fps_0_5,640,360,0.500000,0.529695,grayscale,50,20.766667,11,191626,17420.545,17040.000,19774.000,21081,73820.610,73.820610,3001.342536,40.657244,2.459586,36.371684
|
||||
fps_sweep,fps_0_2,640,360,0.200000,0.240770,grayscale,50,20.766667,5,91496,18299.200,18467.000,20651.800,21081,35247.255,35.247255,3001.342536,85.151100,1.174383,35.860330
|
||||
resolution_sweep,resolution_1280x720,1280,720,5.000000,5.008026,grayscale,50,20.766667,104,5506628,52948.346,52634.000,57821.150,73994,2121333.419,2121.333419,3001.342536,1.414838,70.679484,39.607731
|
||||
resolution_sweep,resolution_960x540,960,540,5.000000,5.008026,grayscale,50,20.766667,104,3411900,32806.731,32634.500,35501.600,41897,1314375.602,1314.375602,3001.342536,2.283474,43.792922,38.632041
|
||||
resolution_sweep,resolution_640x360,640,360,5.000000,5.008026,grayscale,50,20.766667,104,1822387,17522.952,17547.000,18910.400,21081,702043.146,702.043146,3001.342536,4.275154,23.390970,36.352519
|
||||
resolution_sweep,resolution_480x270,480,270,5.000000,5.008026,grayscale,50,20.766667,104,1120944,10778.308,10786.000,11463.200,12395,431824.334,431.824334,3001.342536,6.950378,14.387706,36.172087
|
||||
resolution_sweep,resolution_320x180,320,180,5.000000,5.008026,grayscale,50,20.766667,104,590018,5673.250,5679.000,5995.750,6191,227294.254,227.294254,3001.342536,13.204656,7.573086,35.069392
|
||||
resolution_sweep,resolution_160x90,160,90,5.000000,5.008026,grayscale,50,20.766667,104,210050,2019.712,2017.000,2103.400,2156,80918.138,80.918138,3001.342536,37.091097,2.696065,33.585930
|
||||
quality_sweep,quality_color_q90,640,360,5.000000,5.008026,color,90,20.766667,104,5486654,52756.288,52645.500,57012.750,66227,2113638.780,2113.638780,3001.342536,1.419988,70.423111,39.694424
|
||||
quality_sweep,quality_color_q70,640,360,5.000000,5.008026,color,70,20.766667,104,2901304,27897.154,27815.500,30080.600,33877,1117677.303,1117.677303,3001.342536,2.685339,37.239245,36.029412
|
||||
quality_sweep,quality_color_q50,640,360,5.000000,5.008026,color,50,20.766667,104,2137169,20549.702,20514.000,22026.250,24396,823307.480,823.307480,3001.342536,3.645470,27.431307,34.439250
|
||||
quality_sweep,quality_color_q30,640,360,5.000000,5.008026,color,30,20.766667,104,1572112,15116.462,15105.500,16167.600,17386,605629.021,605.629021,3001.342536,4.955744,20.178604,32.766254
|
||||
quality_sweep,quality_color_q20,640,360,5.000000,5.008026,color,20,20.766667,104,1224535,11774.375,11797.000,12633.050,13098,471730.979,471.730979,3001.342536,6.362403,15.717332,31.298703
|
||||
quality_sweep,quality_grayscale_q90,640,360,5.000000,5.008026,grayscale,90,20.766667,104,4697004,45163.500,44959.000,48937.750,57535,1809439.743,1809.439743,3001.342536,1.658714,60.287679,42.574562
|
||||
quality_sweep,quality_grayscale_q70,640,360,5.000000,5.008026,grayscale,70,20.766667,104,2484781,23892.125,23853.000,25744.950,29385,957219.005,957.219005,3001.342536,3.135482,31.893028,38.031656
|
||||
quality_sweep,quality_grayscale_q50,640,360,5.000000,5.008026,grayscale,50,20.766667,104,1822387,17522.952,17547.000,18910.400,21081,702043.146,702.043146,3001.342536,4.275154,23.390970,36.352519
|
||||
quality_sweep,quality_grayscale_q30,640,360,5.000000,5.008026,grayscale,30,20.766667,104,1328179,12770.952,12821.500,13834.150,14903,511658.042,511.658042,3001.342536,5.865915,17.047639,34.701951
|
||||
quality_sweep,quality_grayscale_q20,640,360,5.000000,5.008026,grayscale,20,20.766667,104,1017957,9788.048,9832.500,10597.450,11027,392150.369,392.150369,3001.342536,7.653550,13.065832,33.304362
|
||||
candidate_profiles,candidate_640x360_gray_5fps_q50,640,360,5.000000,5.008026,grayscale,50,20.766667,104,1822387,17522.952,17547.000,18910.400,21081,702043.146,702.043146,3001.342536,4.275154,23.390970,36.352519
|
||||
candidate_profiles,candidate_640x360_gray_2fps_q40,640,360,2.000000,2.022472,grayscale,40,20.766667,42,639531,15226.929,15187.500,16381.750,18122,246368.283,246.368283,3001.342536,12.182341,8.208603,35.588581
|
||||
candidate_profiles,candidate_320x180_color_5fps_q50,320,180,5.000000,5.008026,color,50,20.766667,104,717676,6900.731,6919.500,7309.850,7475,276472.295,276.472295,3001.342536,10.855853,9.211621,32.413850
|
||||
candidate_profiles,candidate_320x180_gray_5fps_q50,320,180,5.000000,5.008026,grayscale,50,20.766667,104,590018,5673.250,5679.000,5995.750,6191,227294.254,227.294254,3001.342536,13.204656,7.573086,35.069392
|
||||
candidate_profiles,candidate_320x180_gray_2fps_q40,320,180,2.000000,2.022472,grayscale,40,20.766667,42,208403,4961.976,4965.000,5224.700,5400,80283.660,80.283660,3001.342536,37.384227,2.674925,34.362114
|
||||
candidate_profiles,candidate_320x180_gray_1fps_q35,320,180,1.000000,1.011236,grayscale,35,20.766667,21,96733,4606.333,4611.000,4836.000,4992,37264.719,37.264719,3001.342536,80.541129,1.241602,34.035117
|
||||
candidate_profiles,candidate_320x180_gray_0_5fps_q35,320,180,0.500000,0.529695,grayscale,35,20.766667,11,50124,4556.727,4493.000,4914.000,4992,19309.406,19.309406,3001.342536,155.434223,0.643359,34.165852
|
||||
candidate_profiles,candidate_320x180_gray_0_2fps_q35,320,180,0.200000,0.240770,grayscale,35,20.766667,5,23618,4723.600,4815.000,4960.800,4992,9098.427,9.098427,3001.342536,329.874884,0.303145,33.741044
|
||||
candidate_profiles,candidate_160x90_gray_1fps_q30,160,90,1.000000,1.011236,grayscale,30,20.766667,21,32743,1559.190,1561.000,1623.000,1632,12613.676,12.613676,3001.342536,237.943530,0.420268,32.031480
|
||||
|
82
data/processed/lab026/lab026_report.txt
Normal file
@@ -0,0 +1,82 @@
|
||||
Lab026. Исследование уменьшения видеопотока для радиоуправления ровером
|
||||
|
||||
Цель: исследовать уменьшение полезной нагрузки видеоканала для передачи через ретранслятор.
|
||||
Исходный файл: data\raw\lab026_rover_source.mp4
|
||||
Размер исходного файла: 7790985 байт
|
||||
Исходное разрешение: 1280x720
|
||||
Исходный FPS: 30.000000
|
||||
Число кадров: 623
|
||||
Длительность: 20.766667 с
|
||||
Приблизительный исходный файловый битрейт: 3001.343 кбит/с
|
||||
|
||||
Исследуется независимое покадровое JPEG-сжатие. JPEG хранится и декодируется только в памяти.
|
||||
Рассчитанный bitrate учитывает только JPEG payload.
|
||||
Не учитываются заголовки радиопакетов, CRC, FEC, преамбула, интервалы, повторы и команды управления.
|
||||
|
||||
Результаты fps_sweep:
|
||||
fps_30: 640x360, 30 кадр/с, grayscale, Q=50, кадров=623, payload=3936.913 кбит/с, PSNR=37.350 дБ, уменьшение=0.76x
|
||||
fps_10: 640x360, 10 кадр/с, grayscale, Q=50, кадров=208, payload=1402.877 кбит/с, PSNR=36.370 дБ, уменьшение=2.14x
|
||||
fps_5: 640x360, 5 кадр/с, grayscale, Q=50, кадров=104, payload=702.043 кбит/с, PSNR=36.353 дБ, уменьшение=4.28x
|
||||
fps_2: 640x360, 2 кадр/с, grayscale, Q=50, кадров=42, payload=284.084 кбит/с, PSNR=36.320 дБ, уменьшение=10.56x
|
||||
fps_1: 640x360, 1 кадр/с, grayscale, Q=50, кадров=21, payload=142.273 кбит/с, PSNR=36.288 дБ, уменьшение=21.10x
|
||||
fps_0_5: 640x360, 0.5 кадр/с, grayscale, Q=50, кадров=11, payload=73.821 кбит/с, PSNR=36.372 дБ, уменьшение=40.66x
|
||||
fps_0_2: 640x360, 0.2 кадр/с, grayscale, Q=50, кадров=5, payload=35.247 кбит/с, PSNR=35.860 дБ, уменьшение=85.15x
|
||||
|
||||
Результаты resolution_sweep:
|
||||
resolution_1280x720: 1280x720, 5 кадр/с, grayscale, Q=50, кадров=104, payload=2121.333 кбит/с, PSNR=39.608 дБ, уменьшение=1.41x
|
||||
resolution_960x540: 960x540, 5 кадр/с, grayscale, Q=50, кадров=104, payload=1314.376 кбит/с, PSNR=38.632 дБ, уменьшение=2.28x
|
||||
resolution_640x360: 640x360, 5 кадр/с, grayscale, Q=50, кадров=104, payload=702.043 кбит/с, PSNR=36.353 дБ, уменьшение=4.28x
|
||||
resolution_480x270: 480x270, 5 кадр/с, grayscale, Q=50, кадров=104, payload=431.824 кбит/с, PSNR=36.172 дБ, уменьшение=6.95x
|
||||
resolution_320x180: 320x180, 5 кадр/с, grayscale, Q=50, кадров=104, payload=227.294 кбит/с, PSNR=35.069 дБ, уменьшение=13.20x
|
||||
resolution_160x90: 160x90, 5 кадр/с, grayscale, Q=50, кадров=104, payload=80.918 кбит/с, PSNR=33.586 дБ, уменьшение=37.09x
|
||||
|
||||
Результаты quality_sweep:
|
||||
quality_color_q90: 640x360, 5 кадр/с, color, Q=90, кадров=104, payload=2113.639 кбит/с, PSNR=39.694 дБ, уменьшение=1.42x
|
||||
quality_color_q70: 640x360, 5 кадр/с, color, Q=70, кадров=104, payload=1117.677 кбит/с, PSNR=36.029 дБ, уменьшение=2.69x
|
||||
quality_color_q50: 640x360, 5 кадр/с, color, Q=50, кадров=104, payload=823.307 кбит/с, PSNR=34.439 дБ, уменьшение=3.65x
|
||||
quality_color_q30: 640x360, 5 кадр/с, color, Q=30, кадров=104, payload=605.629 кбит/с, PSNR=32.766 дБ, уменьшение=4.96x
|
||||
quality_color_q20: 640x360, 5 кадр/с, color, Q=20, кадров=104, payload=471.731 кбит/с, PSNR=31.299 дБ, уменьшение=6.36x
|
||||
quality_grayscale_q90: 640x360, 5 кадр/с, grayscale, Q=90, кадров=104, payload=1809.440 кбит/с, PSNR=42.575 дБ, уменьшение=1.66x
|
||||
quality_grayscale_q70: 640x360, 5 кадр/с, grayscale, Q=70, кадров=104, payload=957.219 кбит/с, PSNR=38.032 дБ, уменьшение=3.14x
|
||||
quality_grayscale_q50: 640x360, 5 кадр/с, grayscale, Q=50, кадров=104, payload=702.043 кбит/с, PSNR=36.353 дБ, уменьшение=4.28x
|
||||
quality_grayscale_q30: 640x360, 5 кадр/с, grayscale, Q=30, кадров=104, payload=511.658 кбит/с, PSNR=34.702 дБ, уменьшение=5.87x
|
||||
quality_grayscale_q20: 640x360, 5 кадр/с, grayscale, Q=20, кадров=104, payload=392.150 кбит/с, PSNR=33.304 дБ, уменьшение=7.65x
|
||||
|
||||
Результаты candidate_profiles:
|
||||
candidate_640x360_gray_5fps_q50: 640x360, 5 кадр/с, grayscale, Q=50, кадров=104, payload=702.043 кбит/с, PSNR=36.353 дБ, уменьшение=4.28x
|
||||
candidate_640x360_gray_2fps_q40: 640x360, 2 кадр/с, grayscale, Q=40, кадров=42, payload=246.368 кбит/с, PSNR=35.589 дБ, уменьшение=12.18x
|
||||
candidate_320x180_color_5fps_q50: 320x180, 5 кадр/с, color, Q=50, кадров=104, payload=276.472 кбит/с, PSNR=32.414 дБ, уменьшение=10.86x
|
||||
candidate_320x180_gray_5fps_q50: 320x180, 5 кадр/с, grayscale, Q=50, кадров=104, payload=227.294 кбит/с, PSNR=35.069 дБ, уменьшение=13.20x
|
||||
candidate_320x180_gray_2fps_q40: 320x180, 2 кадр/с, grayscale, Q=40, кадров=42, payload=80.284 кбит/с, PSNR=34.362 дБ, уменьшение=37.38x
|
||||
candidate_320x180_gray_1fps_q35: 320x180, 1 кадр/с, grayscale, Q=35, кадров=21, payload=37.265 кбит/с, PSNR=34.035 дБ, уменьшение=80.54x
|
||||
candidate_320x180_gray_0_5fps_q35: 320x180, 0.5 кадр/с, grayscale, Q=35, кадров=11, payload=19.309 кбит/с, PSNR=34.166 дБ, уменьшение=155.43x
|
||||
candidate_320x180_gray_0_2fps_q35: 320x180, 0.2 кадр/с, grayscale, Q=35, кадров=5, payload=9.098 кбит/с, PSNR=33.741 дБ, уменьшение=329.87x
|
||||
candidate_160x90_gray_1fps_q30: 160x90, 1 кадр/с, grayscale, Q=30, кадров=21, payload=12.614 кбит/с, PSNR=32.031 дБ, уменьшение=237.94x
|
||||
|
||||
Candidate-профили с payload bitrate не более 100 кбит/с:
|
||||
candidate_320x180_gray_2fps_q40: 320x180, 2 кадр/с, grayscale, Q=40, кадров=42, payload=80.284 кбит/с, PSNR=34.362 дБ, уменьшение=37.38x
|
||||
candidate_320x180_gray_1fps_q35: 320x180, 1 кадр/с, grayscale, Q=35, кадров=21, payload=37.265 кбит/с, PSNR=34.035 дБ, уменьшение=80.54x
|
||||
candidate_320x180_gray_0_5fps_q35: 320x180, 0.5 кадр/с, grayscale, Q=35, кадров=11, payload=19.309 кбит/с, PSNR=34.166 дБ, уменьшение=155.43x
|
||||
candidate_320x180_gray_0_2fps_q35: 320x180, 0.2 кадр/с, grayscale, Q=35, кадров=5, payload=9.098 кбит/с, PSNR=33.741 дБ, уменьшение=329.87x
|
||||
candidate_160x90_gray_1fps_q30: 160x90, 1 кадр/с, grayscale, Q=30, кадров=21, payload=12.614 кбит/с, PSNR=32.031 дБ, уменьшение=237.94x
|
||||
|
||||
Candidate-профили с payload bitrate не более 50 кбит/с:
|
||||
candidate_320x180_gray_1fps_q35: 320x180, 1 кадр/с, grayscale, Q=35, кадров=21, payload=37.265 кбит/с, PSNR=34.035 дБ, уменьшение=80.54x
|
||||
candidate_320x180_gray_0_5fps_q35: 320x180, 0.5 кадр/с, grayscale, Q=35, кадров=11, payload=19.309 кбит/с, PSNR=34.166 дБ, уменьшение=155.43x
|
||||
candidate_320x180_gray_0_2fps_q35: 320x180, 0.2 кадр/с, grayscale, Q=35, кадров=5, payload=9.098 кбит/с, PSNR=33.741 дБ, уменьшение=329.87x
|
||||
candidate_160x90_gray_1fps_q30: 160x90, 1 кадр/с, grayscale, Q=30, кадров=21, payload=12.614 кбит/с, PSNR=32.031 дБ, уменьшение=237.94x
|
||||
|
||||
Candidate-профили с payload bitrate не более 20 кбит/с:
|
||||
candidate_320x180_gray_0_5fps_q35: 320x180, 0.5 кадр/с, grayscale, Q=35, кадров=11, payload=19.309 кбит/с, PSNR=34.166 дБ, уменьшение=155.43x
|
||||
candidate_320x180_gray_0_2fps_q35: 320x180, 0.2 кадр/с, grayscale, Q=35, кадров=5, payload=9.098 кбит/с, PSNR=33.741 дБ, уменьшение=329.87x
|
||||
candidate_160x90_gray_1fps_q30: 160x90, 1 кадр/с, grayscale, Q=30, кадров=21, payload=12.614 кбит/с, PSNR=32.031 дБ, уменьшение=237.94x
|
||||
|
||||
Candidate-профили с payload bitrate не более 10 кбит/с:
|
||||
candidate_320x180_gray_0_2fps_q35: 320x180, 0.2 кадр/с, grayscale, Q=35, кадров=5, payload=9.098 кбит/с, PSNR=33.741 дБ, уменьшение=329.87x
|
||||
|
||||
Профиль с минимальным измеренным битрейтом:
|
||||
candidate_320x180_gray_0_2fps_q35: 320x180, 0.2 кадр/с, grayscale, Q=35, кадров=5, payload=9.098 кбит/с, PSNR=33.741 дБ, уменьшение=329.87x
|
||||
|
||||
Автоматические метрики не заменяют визуальную оценку пригодности изображения для управления ровером.
|
||||
Сравнительное изображение: data\processed\lab026\lab026_candidate_profiles.png
|
||||
Следующий шаг: пользователь должен визуально выбрать пригодные candidate-профили.
|
||||
BIN
data/processed/lab026b/lab026b_bitrate_vs_resolution.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
data/processed/lab026b/lab026b_codec_comparison.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
data/processed/lab026b/lab026b_equal_bitrate_candidates.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
31
data/processed/lab026b/lab026b_profiles.csv
Normal file
@@ -0,0 +1,31 @@
|
||||
experiment,profile_name,mode,width,height,pixel_count,target_fps,actual_fps,bits_per_pixel_before_compression,maximum_gray_levels,maximum_colors,selected_frames,total_payload_bytes,mean_frame_bytes,median_frame_bytes,p95_frame_bytes,max_frame_bytes,payload_bitrate_bps,payload_bitrate_kbps,baseline_bitrate_kbps,difference_from_baseline_kbps,percent_of_baseline_bitrate,compression_ratio_from_unpacked_source,mean_psnr_db,header_bytes_per_frame,palette_bytes_per_frame,notes
|
||||
four_bit_resolution_sweep,320x180_gray8_jpeg_2fps,gray8_jpeg,320,180,57600,2.000000,2.022472,8,256,0,42,208403,4961.976,4965.000,5224.700,5400,80283.660,80.283660,80.283660,-0.000000,100.000000,11.608278,34.362114,0,0,"Grayscale 8 bit, JPEG quality 40."
|
||||
four_bit_resolution_sweep,320x180_gray4_jpeg_2fps,gray4_jpeg,320,180,57600,2.000000,2.022472,4,16,0,42,229518,5464.714,5466.000,5727.250,5822,88417.849,88.417849,80.283660,8.134189,110.131812,10.540350,32.588941,0,0,"Grayscale quantized to 16 levels, restored to uint8, JPEG quality 40."
|
||||
four_bit_resolution_sweep,320x180_gray4_packed_zlib_2fps,gray4_packed_zlib,320,180,57600,2.000000,2.022472,4,16,0,42,441074,10501.762,10444.000,11194.300,12317,169916.148,169.916148,80.283660,89.632488,211.644745,5.484794,34.397012,16,0,"Two 4-bit grayscale indices per byte, zlib level 9, 16-byte frame header."
|
||||
four_bit_resolution_sweep,320x180_color16_png_2fps,color16_png,320,180,57600,2.000000,2.022472,4,0,16,42,371520,8845.714,8607.500,10578.350,10794,143121.669,143.121669,80.283660,62.838009,178.269986,19.534884,25.384724,0,0,"Pillow palette image, maximum 16 colors, no dithering, optimized indexed PNG."
|
||||
four_bit_resolution_sweep,320x180_color16_packed_zlib_2fps,color16_packed_zlib,320,180,57600,2.000000,2.022472,4,0,16,42,364892,8687.905,8452.000,10414.800,10652,140568.347,140.568347,80.283660,60.284687,175.089609,19.889721,25.384724,16,48,"Two 4-bit palette indices per byte, zlib level 9, 16-byte header and fixed 48-byte palette."
|
||||
four_bit_resolution_sweep,400x225_gray8_jpeg_2fps,gray8_jpeg,400,225,90000,2.000000,2.022472,8,256,0,42,297273,7077.929,7063.500,7473.000,7841,114519.294,114.519294,80.283660,34.235634,142.643340,12.715585,34.942886,0,0,"Grayscale 8 bit, JPEG quality 40."
|
||||
four_bit_resolution_sweep,400x225_gray4_jpeg_2fps,gray4_jpeg,400,225,90000,2.000000,2.022472,4,16,0,42,332268,7911.143,7920.500,8353.700,8554,128000.514,128.000514,80.283660,47.716854,159.435324,11.376359,32.883090,0,0,"Grayscale quantized to 16 levels, restored to uint8, JPEG quality 40."
|
||||
four_bit_resolution_sweep,400x225_gray4_packed_zlib_2fps,gray4_packed_zlib,400,225,90000,2.000000,2.022472,4,16,0,42,636862,15163.381,15120.500,16266.050,18356,245340.096,245.340096,80.283660,165.056436,305.591569,5.935352,34.399552,16,0,"Two 4-bit grayscale indices per byte, zlib level 9, 16-byte frame header."
|
||||
four_bit_resolution_sweep,400x225_color16_png_2fps,color16_png,400,225,90000,2.000000,2.022472,4,0,16,42,528261,12577.643,12153.000,15191.650,16128,203503.435,203.503435,80.283660,123.219775,253.480515,21.466661,25.344339,0,0,"Pillow palette image, maximum 16 colors, no dithering, optimized indexed PNG."
|
||||
four_bit_resolution_sweep,400x225_color16_packed_zlib_2fps,color16_packed_zlib,400,225,90000,2.000000,2.022472,4,0,16,42,520725,12398.214,11977.000,15015.100,15966,200600.321,200.600321,80.283660,120.316661,249.864444,21.777330,25.344339,16,48,"Two 4-bit palette indices per byte, zlib level 9, 16-byte header and fixed 48-byte palette."
|
||||
four_bit_resolution_sweep,448x252_gray8_jpeg_2fps,gray8_jpeg,448,252,112896,2.000000,2.022472,8,256,0,42,353578,8418.524,8413.000,8885.950,9512,136209.823,136.209823,80.283660,55.926163,169.660705,13.410427,35.301071,0,0,"Grayscale 8 bit, JPEG quality 40."
|
||||
four_bit_resolution_sweep,448x252_gray4_jpeg_2fps,gray4_jpeg,448,252,112896,2.000000,2.022472,4,16,0,42,397126,9455.381,9467.500,9996.400,10450,152985.939,152.985939,80.283660,72.702279,190.556757,11.939868,33.060439,0,0,"Grayscale quantized to 16 levels, restored to uint8, JPEG quality 40."
|
||||
four_bit_resolution_sweep,448x252_gray4_packed_zlib_2fps,gray4_packed_zlib,448,252,112896,2.000000,2.022472,4,16,0,42,762082,18144.810,18111.500,19504.750,22433,293578.941,293.578941,80.283660,213.295281,365.677076,6.221945,34.399841,16,0,"Two 4-bit grayscale indices per byte, zlib level 9, 16-byte frame header."
|
||||
four_bit_resolution_sweep,448x252_color16_png_2fps,color16_png,448,252,112896,2.000000,2.022472,4,0,16,42,626516,14917.048,14409.500,18239.600,18695,241354.478,241.354478,80.283660,161.070818,300.627149,22.704761,25.369963,0,0,"Pillow palette image, maximum 16 colors, no dithering, optimized indexed PNG."
|
||||
four_bit_resolution_sweep,448x252_color16_packed_zlib_2fps,color16_packed_zlib,448,252,112896,2.000000,2.022472,4,0,16,42,618164,14718.190,14221.500,18022.000,18517,238137.014,238.137014,80.283660,157.853354,296.619529,23.011524,25.369963,16,48,"Two 4-bit palette indices per byte, zlib level 9, 16-byte header and fixed 48-byte palette."
|
||||
four_bit_resolution_sweep,480x270_gray8_jpeg_2fps,gray8_jpeg,480,270,129600,2.000000,2.022472,8,256,0,42,393752,9375.048,9365.500,9919.950,10678,151686.164,151.686164,80.283660,71.402504,188.937779,13.823930,35.412999,0,0,"Grayscale 8 bit, JPEG quality 40."
|
||||
four_bit_resolution_sweep,480x270_gray4_jpeg_2fps,gray4_jpeg,480,270,129600,2.000000,2.022472,4,16,0,42,443419,10557.595,10575.500,11152.550,11737,170819.518,170.819518,80.283660,90.535858,212.769969,12.275523,33.099458,0,0,"Grayscale quantized to 16 levels, restored to uint8, JPEG quality 40."
|
||||
four_bit_resolution_sweep,480x270_gray4_packed_zlib_2fps,gray4_packed_zlib,480,270,129600,2.000000,2.022472,4,16,0,42,854354,20341.762,20276.500,21857.900,25369,329125.136,329.125136,80.283660,248.841476,409.952830,6.371130,34.401420,16,0,"Two 4-bit grayscale indices per byte, zlib level 9, 16-byte frame header."
|
||||
four_bit_resolution_sweep,480x270_color16_png_2fps,color16_png,480,270,129600,2.000000,2.022472,4,0,16,42,701871,16711.214,16249.500,20469.850,21005,270383.692,270.383692,80.283660,190.100032,336.785458,23.265814,25.368870,0,0,"Pillow palette image, maximum 16 colors, no dithering, optimized indexed PNG."
|
||||
four_bit_resolution_sweep,480x270_color16_packed_zlib_2fps,color16_packed_zlib,480,270,129600,2.000000,2.022472,4,0,16,42,693190,16504.524,16007.500,20249.750,20809,267039.486,267.039486,80.283660,186.755826,332.619971,23.557178,25.368870,16,48,"Two 4-bit palette indices per byte, zlib level 9, 16-byte header and fixed 48-byte palette."
|
||||
four_bit_resolution_sweep,560x315_gray8_jpeg_2fps,gray8_jpeg,560,315,176400,2.000000,2.022472,8,256,0,42,507535,12084.167,12068.500,12860.900,13999,195519.101,195.519101,80.283660,115.235441,243.535361,14.597614,35.954946,0,0,"Grayscale 8 bit, JPEG quality 40."
|
||||
four_bit_resolution_sweep,560x315_gray4_jpeg_2fps,gray4_jpeg,560,315,176400,2.000000,2.022472,4,16,0,42,575350,13698.810,13674.500,14558.450,15458,221643.660,221.643660,80.283660,141.360000,276.075679,12.877031,33.337152,0,0,"Grayscale quantized to 16 levels, restored to uint8, JPEG quality 40."
|
||||
four_bit_resolution_sweep,560x315_gray4_packed_zlib_2fps,gray4_packed_zlib,560,315,176400,2.000000,2.022472,4,16,0,42,1093024,26024.381,25851.500,28055.650,33171,421068.636,421.068636,80.283660,340.784976,524.476133,6.778259,34.403027,16,0,"Two 4-bit grayscale indices per byte, zlib level 9, 16-byte frame header."
|
||||
four_bit_resolution_sweep,560x315_color16_png_2fps,color16_png,560,315,176400,2.000000,2.022472,4,0,16,42,894967,21308.738,20851.500,25906.100,27345,344770.594,344.770594,80.283660,264.486934,429.440554,24.834882,25.323519,0,0,"Pillow palette image, maximum 16 colors, no dithering, optimized indexed PNG."
|
||||
four_bit_resolution_sweep,560x315_color16_packed_zlib_2fps,color16_packed_zlib,560,315,176400,2.000000,2.022472,4,0,16,42,885156,21075.143,20622.000,25686.650,27166,340991.075,340.991075,80.283660,260.707415,424.732848,25.110150,25.323519,16,48,"Two 4-bit palette indices per byte, zlib level 9, 16-byte header and fixed 48-byte palette."
|
||||
four_bit_resolution_sweep,640x360_gray8_jpeg_2fps,gray8_jpeg,640,360,230400,2.000000,2.022472,8,256,0,42,639531,15226.929,15187.500,16381.750,18122,246368.283,246.368283,80.283660,166.084623,306.872261,15.131088,35.588581,0,0,"Grayscale 8 bit, JPEG quality 40."
|
||||
four_bit_resolution_sweep,640x360_gray4_jpeg_2fps,gray4_jpeg,640,360,230400,2.000000,2.022472,4,16,0,42,726335,17293.690,17220.500,18557.350,19938,279808.026,279.808026,80.283660,199.524366,348.524252,13.322778,33.048695,0,0,"Grayscale quantized to 16 levels, restored to uint8, JPEG quality 40."
|
||||
four_bit_resolution_sweep,640x360_gray4_packed_zlib_2fps,gray4_packed_zlib,640,360,230400,2.000000,2.022472,4,16,0,42,1394769,33208.786,32974.500,35959.800,43514,537310.690,537.310690,80.283660,457.027030,669.265315,6.937923,34.404228,16,0,"Two 4-bit grayscale indices per byte, zlib level 9, 16-byte frame header."
|
||||
four_bit_resolution_sweep,640x360_color16_png_2fps,color16_png,640,360,230400,2.000000,2.022472,4,0,16,42,1140488,27154.476,26267.500,33424.950,36017,439353.323,439.353323,80.283660,359.069663,547.251237,25.454367,25.259621,0,0,"Pillow palette image, maximum 16 colors, no dithering, optimized indexed PNG."
|
||||
four_bit_resolution_sweep,640x360_color16_packed_zlib_2fps,color16_packed_zlib,640,360,230400,2.000000,2.022472,4,0,16,42,1128711,26874.071,25978.500,33021.450,35603,434816.437,434.816437,80.283660,354.532777,541.600167,25.719958,25.259621,16,48,"Two 4-bit palette indices per byte, zlib level 9, 16-byte header and fixed 48-byte palette."
|
||||
|
90
data/processed/lab026b/lab026b_report.txt
Normal file
@@ -0,0 +1,90 @@
|
||||
Lab026B. Увеличение разрешения кадра за счёт глубины цвета 4 бита
|
||||
|
||||
Цель: проверить увеличение разрешения при битрейте, близком к контрольным 80.283660 кбит/с.
|
||||
Исходное видео: data\raw\lab026_rover_source.mp4
|
||||
Размер исходного видео: 7790985 байт
|
||||
Исходное разрешение: 1280×720
|
||||
Исходный FPS: 30.000000
|
||||
Число кадров: 623
|
||||
Длительность: 20.766667 с
|
||||
|
||||
4-битная яркость: индекс 0...15, то есть 16 уровней серого на пиксель.
|
||||
4-битный индексированный цвет: один индекс 0...15 на пиксель и палитра максимум из 16 RGB-цветов.
|
||||
Это не RGB444: RGB444 требует по 4 бита на каждый из трёх каналов, то есть 12 бит на пиксель.
|
||||
|
||||
Режимы:
|
||||
gray8_jpeg — контрольный grayscale JPEG Q40.
|
||||
gray4_jpeg — 16 уровней серого, восстановление uint8, JPEG Q40.
|
||||
gray4_packed_zlib — 4-битные индексы, два на байт, zlib level 9 и заголовок 16 байт.
|
||||
color16_png — палитра максимум 16 цветов, без дизеринга, индексированный PNG в памяти.
|
||||
color16_packed_zlib — 4-битные индексы, zlib level 9, заголовок 16 байт и фиксированная палитра 48 байт.
|
||||
|
||||
Контрольный baseline: 80.283660 кбит/с.
|
||||
CRC, FEC, радиозаголовки и пакетирование пока не учтены.
|
||||
Статические метрики не учитывают задержку видеоканала.
|
||||
|
||||
Полные результаты 30 профилей:
|
||||
320x180_gray8_jpeg_2fps: mode=gray8_jpeg, 320x180, frames=42, actual_fps=2.022472, total=208403 bytes, mean=4961.976, median=4965.000, p95=5224.700, max=5400, bitrate=80.283660 kbit/s, baseline_diff=-0.000000, baseline_percent=100.000%, compression=11.608x, PSNR=34.362114 dB
|
||||
320x180_gray4_jpeg_2fps: mode=gray4_jpeg, 320x180, frames=42, actual_fps=2.022472, total=229518 bytes, mean=5464.714, median=5466.000, p95=5727.250, max=5822, bitrate=88.417849 kbit/s, baseline_diff=8.134189, baseline_percent=110.132%, compression=10.540x, PSNR=32.588941 dB
|
||||
320x180_gray4_packed_zlib_2fps: mode=gray4_packed_zlib, 320x180, frames=42, actual_fps=2.022472, total=441074 bytes, mean=10501.762, median=10444.000, p95=11194.300, max=12317, bitrate=169.916148 kbit/s, baseline_diff=89.632488, baseline_percent=211.645%, compression=5.485x, PSNR=34.397012 dB
|
||||
320x180_color16_png_2fps: mode=color16_png, 320x180, frames=42, actual_fps=2.022472, total=371520 bytes, mean=8845.714, median=8607.500, p95=10578.350, max=10794, bitrate=143.121669 kbit/s, baseline_diff=62.838009, baseline_percent=178.270%, compression=19.535x, PSNR=25.384724 dB
|
||||
320x180_color16_packed_zlib_2fps: mode=color16_packed_zlib, 320x180, frames=42, actual_fps=2.022472, total=364892 bytes, mean=8687.905, median=8452.000, p95=10414.800, max=10652, bitrate=140.568347 kbit/s, baseline_diff=60.284687, baseline_percent=175.090%, compression=19.890x, PSNR=25.384724 dB
|
||||
400x225_gray8_jpeg_2fps: mode=gray8_jpeg, 400x225, frames=42, actual_fps=2.022472, total=297273 bytes, mean=7077.929, median=7063.500, p95=7473.000, max=7841, bitrate=114.519294 kbit/s, baseline_diff=34.235634, baseline_percent=142.643%, compression=12.716x, PSNR=34.942886 dB
|
||||
400x225_gray4_jpeg_2fps: mode=gray4_jpeg, 400x225, frames=42, actual_fps=2.022472, total=332268 bytes, mean=7911.143, median=7920.500, p95=8353.700, max=8554, bitrate=128.000514 kbit/s, baseline_diff=47.716854, baseline_percent=159.435%, compression=11.376x, PSNR=32.883090 dB
|
||||
400x225_gray4_packed_zlib_2fps: mode=gray4_packed_zlib, 400x225, frames=42, actual_fps=2.022472, total=636862 bytes, mean=15163.381, median=15120.500, p95=16266.050, max=18356, bitrate=245.340096 kbit/s, baseline_diff=165.056436, baseline_percent=305.592%, compression=5.935x, PSNR=34.399552 dB
|
||||
400x225_color16_png_2fps: mode=color16_png, 400x225, frames=42, actual_fps=2.022472, total=528261 bytes, mean=12577.643, median=12153.000, p95=15191.650, max=16128, bitrate=203.503435 kbit/s, baseline_diff=123.219775, baseline_percent=253.481%, compression=21.467x, PSNR=25.344339 dB
|
||||
400x225_color16_packed_zlib_2fps: mode=color16_packed_zlib, 400x225, frames=42, actual_fps=2.022472, total=520725 bytes, mean=12398.214, median=11977.000, p95=15015.100, max=15966, bitrate=200.600321 kbit/s, baseline_diff=120.316661, baseline_percent=249.864%, compression=21.777x, PSNR=25.344339 dB
|
||||
448x252_gray8_jpeg_2fps: mode=gray8_jpeg, 448x252, frames=42, actual_fps=2.022472, total=353578 bytes, mean=8418.524, median=8413.000, p95=8885.950, max=9512, bitrate=136.209823 kbit/s, baseline_diff=55.926163, baseline_percent=169.661%, compression=13.410x, PSNR=35.301071 dB
|
||||
448x252_gray4_jpeg_2fps: mode=gray4_jpeg, 448x252, frames=42, actual_fps=2.022472, total=397126 bytes, mean=9455.381, median=9467.500, p95=9996.400, max=10450, bitrate=152.985939 kbit/s, baseline_diff=72.702279, baseline_percent=190.557%, compression=11.940x, PSNR=33.060439 dB
|
||||
448x252_gray4_packed_zlib_2fps: mode=gray4_packed_zlib, 448x252, frames=42, actual_fps=2.022472, total=762082 bytes, mean=18144.810, median=18111.500, p95=19504.750, max=22433, bitrate=293.578941 kbit/s, baseline_diff=213.295281, baseline_percent=365.677%, compression=6.222x, PSNR=34.399841 dB
|
||||
448x252_color16_png_2fps: mode=color16_png, 448x252, frames=42, actual_fps=2.022472, total=626516 bytes, mean=14917.048, median=14409.500, p95=18239.600, max=18695, bitrate=241.354478 kbit/s, baseline_diff=161.070818, baseline_percent=300.627%, compression=22.705x, PSNR=25.369963 dB
|
||||
448x252_color16_packed_zlib_2fps: mode=color16_packed_zlib, 448x252, frames=42, actual_fps=2.022472, total=618164 bytes, mean=14718.190, median=14221.500, p95=18022.000, max=18517, bitrate=238.137014 kbit/s, baseline_diff=157.853354, baseline_percent=296.620%, compression=23.012x, PSNR=25.369963 dB
|
||||
480x270_gray8_jpeg_2fps: mode=gray8_jpeg, 480x270, frames=42, actual_fps=2.022472, total=393752 bytes, mean=9375.048, median=9365.500, p95=9919.950, max=10678, bitrate=151.686164 kbit/s, baseline_diff=71.402504, baseline_percent=188.938%, compression=13.824x, PSNR=35.412999 dB
|
||||
480x270_gray4_jpeg_2fps: mode=gray4_jpeg, 480x270, frames=42, actual_fps=2.022472, total=443419 bytes, mean=10557.595, median=10575.500, p95=11152.550, max=11737, bitrate=170.819518 kbit/s, baseline_diff=90.535858, baseline_percent=212.770%, compression=12.276x, PSNR=33.099458 dB
|
||||
480x270_gray4_packed_zlib_2fps: mode=gray4_packed_zlib, 480x270, frames=42, actual_fps=2.022472, total=854354 bytes, mean=20341.762, median=20276.500, p95=21857.900, max=25369, bitrate=329.125136 kbit/s, baseline_diff=248.841476, baseline_percent=409.953%, compression=6.371x, PSNR=34.401420 dB
|
||||
480x270_color16_png_2fps: mode=color16_png, 480x270, frames=42, actual_fps=2.022472, total=701871 bytes, mean=16711.214, median=16249.500, p95=20469.850, max=21005, bitrate=270.383692 kbit/s, baseline_diff=190.100032, baseline_percent=336.785%, compression=23.266x, PSNR=25.368870 dB
|
||||
480x270_color16_packed_zlib_2fps: mode=color16_packed_zlib, 480x270, frames=42, actual_fps=2.022472, total=693190 bytes, mean=16504.524, median=16007.500, p95=20249.750, max=20809, bitrate=267.039486 kbit/s, baseline_diff=186.755826, baseline_percent=332.620%, compression=23.557x, PSNR=25.368870 dB
|
||||
560x315_gray8_jpeg_2fps: mode=gray8_jpeg, 560x315, frames=42, actual_fps=2.022472, total=507535 bytes, mean=12084.167, median=12068.500, p95=12860.900, max=13999, bitrate=195.519101 kbit/s, baseline_diff=115.235441, baseline_percent=243.535%, compression=14.598x, PSNR=35.954946 dB
|
||||
560x315_gray4_jpeg_2fps: mode=gray4_jpeg, 560x315, frames=42, actual_fps=2.022472, total=575350 bytes, mean=13698.810, median=13674.500, p95=14558.450, max=15458, bitrate=221.643660 kbit/s, baseline_diff=141.360000, baseline_percent=276.076%, compression=12.877x, PSNR=33.337152 dB
|
||||
560x315_gray4_packed_zlib_2fps: mode=gray4_packed_zlib, 560x315, frames=42, actual_fps=2.022472, total=1093024 bytes, mean=26024.381, median=25851.500, p95=28055.650, max=33171, bitrate=421.068636 kbit/s, baseline_diff=340.784976, baseline_percent=524.476%, compression=6.778x, PSNR=34.403027 dB
|
||||
560x315_color16_png_2fps: mode=color16_png, 560x315, frames=42, actual_fps=2.022472, total=894967 bytes, mean=21308.738, median=20851.500, p95=25906.100, max=27345, bitrate=344.770594 kbit/s, baseline_diff=264.486934, baseline_percent=429.441%, compression=24.835x, PSNR=25.323519 dB
|
||||
560x315_color16_packed_zlib_2fps: mode=color16_packed_zlib, 560x315, frames=42, actual_fps=2.022472, total=885156 bytes, mean=21075.143, median=20622.000, p95=25686.650, max=27166, bitrate=340.991075 kbit/s, baseline_diff=260.707415, baseline_percent=424.733%, compression=25.110x, PSNR=25.323519 dB
|
||||
640x360_gray8_jpeg_2fps: mode=gray8_jpeg, 640x360, frames=42, actual_fps=2.022472, total=639531 bytes, mean=15226.929, median=15187.500, p95=16381.750, max=18122, bitrate=246.368283 kbit/s, baseline_diff=166.084623, baseline_percent=306.872%, compression=15.131x, PSNR=35.588581 dB
|
||||
640x360_gray4_jpeg_2fps: mode=gray4_jpeg, 640x360, frames=42, actual_fps=2.022472, total=726335 bytes, mean=17293.690, median=17220.500, p95=18557.350, max=19938, bitrate=279.808026 kbit/s, baseline_diff=199.524366, baseline_percent=348.524%, compression=13.323x, PSNR=33.048695 dB
|
||||
640x360_gray4_packed_zlib_2fps: mode=gray4_packed_zlib, 640x360, frames=42, actual_fps=2.022472, total=1394769 bytes, mean=33208.786, median=32974.500, p95=35959.800, max=43514, bitrate=537.310690 kbit/s, baseline_diff=457.027030, baseline_percent=669.265%, compression=6.938x, PSNR=34.404228 dB
|
||||
640x360_color16_png_2fps: mode=color16_png, 640x360, frames=42, actual_fps=2.022472, total=1140488 bytes, mean=27154.476, median=26267.500, p95=33424.950, max=36017, bitrate=439.353323 kbit/s, baseline_diff=359.069663, baseline_percent=547.251%, compression=25.454x, PSNR=25.259621 dB
|
||||
640x360_color16_packed_zlib_2fps: mode=color16_packed_zlib, 640x360, frames=42, actual_fps=2.022472, total=1128711 bytes, mean=26874.071, median=25978.500, p95=33021.450, max=35603, bitrate=434.816437 kbit/s, baseline_diff=354.532777, baseline_percent=541.600%, compression=25.720x, PSNR=25.259621 dB
|
||||
|
||||
Профили 70–90 кбит/с:
|
||||
320x180_gray8_jpeg_2fps: mode=gray8_jpeg, 320x180, frames=42, actual_fps=2.022472, total=208403 bytes, mean=4961.976, median=4965.000, p95=5224.700, max=5400, bitrate=80.283660 kbit/s, baseline_diff=-0.000000, baseline_percent=100.000%, compression=11.608x, PSNR=34.362114 dB
|
||||
320x180_gray4_jpeg_2fps: mode=gray4_jpeg, 320x180, frames=42, actual_fps=2.022472, total=229518 bytes, mean=5464.714, median=5466.000, p95=5727.250, max=5822, bitrate=88.417849 kbit/s, baseline_diff=8.134189, baseline_percent=110.132%, compression=10.540x, PSNR=32.588941 dB
|
||||
|
||||
Профили до baseline:
|
||||
320x180_gray8_jpeg_2fps: mode=gray8_jpeg, 320x180, frames=42, actual_fps=2.022472, total=208403 bytes, mean=4961.976, median=4965.000, p95=5224.700, max=5400, bitrate=80.283660 kbit/s, baseline_diff=-0.000000, baseline_percent=100.000%, compression=11.608x, PSNR=34.362114 dB
|
||||
|
||||
Профили до 100 кбит/с:
|
||||
320x180_gray8_jpeg_2fps: mode=gray8_jpeg, 320x180, frames=42, actual_fps=2.022472, total=208403 bytes, mean=4961.976, median=4965.000, p95=5224.700, max=5400, bitrate=80.283660 kbit/s, baseline_diff=-0.000000, baseline_percent=100.000%, compression=11.608x, PSNR=34.362114 dB
|
||||
320x180_gray4_jpeg_2fps: mode=gray4_jpeg, 320x180, frames=42, actual_fps=2.022472, total=229518 bytes, mean=5464.714, median=5466.000, p95=5727.250, max=5822, bitrate=88.417849 kbit/s, baseline_diff=8.134189, baseline_percent=110.132%, compression=10.540x, PSNR=32.588941 dB
|
||||
|
||||
Цветные color16-профили до 100 кбит/с:
|
||||
Нет.
|
||||
|
||||
Профили больше 320x180 и не выше baseline:
|
||||
Нет.
|
||||
|
||||
Максимальное разрешение до baseline:
|
||||
320x180_gray8_jpeg_2fps: mode=gray8_jpeg, 320x180, frames=42, actual_fps=2.022472, total=208403 bytes, mean=4961.976, median=4965.000, p95=5224.700, max=5400, bitrate=80.283660 kbit/s, baseline_diff=-0.000000, baseline_percent=100.000%, compression=11.608x, PSNR=34.362114 dB
|
||||
|
||||
Максимальное разрешение до 100 кбит/с:
|
||||
320x180_gray8_jpeg_2fps: mode=gray8_jpeg, 320x180, frames=42, actual_fps=2.022472, total=208403 bytes, mean=4961.976, median=4965.000, p95=5224.700, max=5400, bitrate=80.283660 kbit/s, baseline_diff=-0.000000, baseline_percent=100.000%, compression=11.608x, PSNR=34.362114 dB
|
||||
320x180_gray4_jpeg_2fps: mode=gray4_jpeg, 320x180, frames=42, actual_fps=2.022472, total=229518 bytes, mean=5464.714, median=5466.000, p95=5727.250, max=5822, bitrate=88.417849 kbit/s, baseline_diff=8.134189, baseline_percent=110.132%, compression=10.540x, PSNR=32.588941 dB
|
||||
|
||||
Сравнение gray4 и color16:
|
||||
Минимальный gray4 bitrate: 88.417849 кбит/с.
|
||||
Минимальный color16 bitrate: 140.568347 кбит/с.
|
||||
Размер палитры packed color16: 48 байт на кадр.
|
||||
Размер компактного заголовка packed-режимов: 16 байт на кадр.
|
||||
|
||||
Нельзя автоматически объявлять профиль безопасным для управления ровером по одной статической метрике.
|
||||
Сравнительное изображение: data\processed\lab026b\lab026b_equal_bitrate_candidates.png
|
||||
Следующий шаг: ручная визуальная оценка пользователем.
|
||||
BIN
data/processed/lab027/lab027_bitrate_comparison.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
data/processed/lab027/lab027_candidate_profiles.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
10
data/processed/lab027/lab027_profiles.csv
Normal file
@@ -0,0 +1,10 @@
|
||||
profile_name,base_width,base_height,base_fps,base_quality,roi_enabled,roi_x_min,roi_y_min,roi_x_max,roi_y_max,roi_width,roi_height,roi_fps,roi_quality,source_duration_s,selected_base_frames,selected_roi_frames,actual_base_fps,actual_roi_fps,total_base_bytes,total_roi_bytes,total_payload_bytes,mean_base_frame_bytes,mean_roi_frame_bytes,p95_base_frame_bytes,p95_roi_frame_bytes,max_base_frame_bytes,max_roi_frame_bytes,base_bitrate_kbps,roi_bitrate_kbps,total_payload_bitrate_kbps,baseline_bitrate_kbps,difference_from_baseline_kbps,percent_of_baseline,mean_base_jpeg_psnr_db,mean_roi_jpeg_psnr_db,mean_reconstructed_full_psnr_db,mean_reconstructed_roi_psnr_db
|
||||
baseline_320x180_gray_2fps_q40,320,180,2.000000,40,0,0.000000,0.000000,0.000000,0.000000,0,0,0.000000,0,20.766667,42,0,2.022472,0.000000,208368,0,208368,4961.143,0.000,5226.950,0.000,5390,0,80.270177,0.000000,80.270177,80.283660,-0.013483,99.983205,34.364165,,20.757991,
|
||||
base240_1fps_q25_roi320_2fps_q35,240,135,1.000000,25,1,0.200000,0.420000,0.800000,1.000000,320,180,2.000000,35,20.766667,21,42,1.011236,2.022472,50858,191270,242128,2421.810,4554.048,2554.000,5044.500,2569,5172,19.592167,73.683467,93.275634,80.283660,12.991974,116.182588,32.370927,34.103745,19.657187,19.574377
|
||||
base240_0_5fps_q25_roi320_2fps_q35,240,135,0.500000,25,1,0.200000,0.420000,0.800000,1.000000,320,180,2.000000,35,20.766667,11,42,0.529695,2.022472,26348,191270,217618,2395.273,4554.048,2561.500,5044.500,2569,5172,10.150112,73.683467,83.833579,80.283660,3.549919,104.421721,32.474572,34.103745,18.613817,19.574377
|
||||
base160_1fps_q25_roi320_2fps_q35,160,90,1.000000,25,1,0.200000,0.420000,0.800000,1.000000,320,180,2.000000,35,20.766667,21,42,1.011236,2.022472,29882,191270,221152,1422.952,4554.048,1484.000,5044.500,1492,5172,11.511525,73.683467,85.194992,80.283660,4.911332,106.117474,31.458773,34.103745,19.701304,19.574377
|
||||
base240_1fps_q25_roi448_1fps_q30,240,135,1.000000,25,1,0.200000,0.420000,0.800000,1.000000,448,252,1.000000,30,20.766667,21,21,1.011236,1.011236,50858,147787,198645,2421.810,7037.476,2554.000,7860.000,2569,8507,19.592167,56.932392,76.524559,80.283660,-3.759101,95.317725,32.370927,34.666932,19.098790,17.871555
|
||||
base240_0_5fps_q25_roi448_1fps_q30,240,135,0.500000,25,1,0.200000,0.420000,0.800000,1.000000,448,252,1.000000,30,20.766667,11,21,0.529695,1.011236,26348,147787,174135,2395.273,7037.476,2561.500,7860.000,2569,8507,10.150112,56.932392,67.082504,80.283660,-13.201156,83.556858,32.474572,34.666932,18.113877,17.871555
|
||||
base320_1fps_q30_roi320_1fps_q35,320,180,1.000000,30,1,0.200000,0.420000,0.800000,1.000000,320,180,1.000000,35,20.766667,21,21,1.011236,1.011236,87901,95367,183268,4185.762,4541.286,4391.000,5048.000,4537,5172,33.862343,36.738491,70.600835,80.283660,-9.682825,87.939233,33.539230,34.103514,19.121457,17.888190
|
||||
base240_1fps_q20_roi320_1fps_q30,240,135,1.000000,20,1,0.200000,0.420000,0.800000,1.000000,320,180,1.000000,30,20.766667,21,21,1.011236,1.011236,44598,86458,131056,2123.714,4117.048,2231.000,4551.000,2251,4614,17.180610,33.306453,50.487063,80.283660,-29.796597,62.885851,31.643522,33.598412,19.107784,17.873870
|
||||
base160_0_5fps_q20_roi320_2fps_q30,160,90,0.500000,20,1,0.200000,0.420000,0.800000,1.000000,320,180,2.000000,30,20.766667,11,42,0.529695,2.022472,13820,173455,187275,1256.364,4129.881,1326.000,4548.450,1347,4614,5.323917,66.820546,72.144462,80.283660,-8.139198,89.861950,30.823518,33.595566,18.664976,19.549559
|
||||
|
BIN
data/processed/lab027/lab027_quality_comparison.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
74
data/processed/lab027/lab027_report.txt
Normal file
@@ -0,0 +1,74 @@
|
||||
Lab027. Многомасштабная передача изображения: общий кадр и область интереса
|
||||
|
||||
Цель: проверить совместную передачу общего кадра и фиксированной ROI при payload около 80–100 кбит/с.
|
||||
Исходное видео: data\raw\lab026_rover_source.mp4
|
||||
Размер файла: 7790985 байт
|
||||
Исходное разрешение: 1280×720
|
||||
Исходный FPS: 30.000000
|
||||
Число кадров: 623
|
||||
Длительность: 20.766667 с
|
||||
|
||||
BASE: полный grayscale-кадр пониженного разрешения с собственной частотой и JPEG quality.
|
||||
ROI: фиксированная центральная нижняя область исходного grayscale-кадра с независимыми разрешением, частотой и JPEG quality.
|
||||
Нормализованные координаты ROI: x=0.20...0.80, y=0.42...1.00.
|
||||
Пиксельные координаты ROI исходного кадра: x=256...1024, y=302...720.
|
||||
|
||||
Контрольный профиль: 320×180 grayscale, 2 fps, JPEG Q40.
|
||||
Контрольный baseline: 80.283660 кбит/с.
|
||||
|
||||
Результаты всех девяти профилей:
|
||||
baseline_320x180_gray_2fps_q40: BASE=320x180, 2.000 fps, Q40, base_frames=42, base=80.270177 kbit/s; ROI=0x0, 0.000 fps, Q0, roi_frames=0, roi=0.000000 kbit/s; total=80.270177 kbit/s, baseline_diff=-0.013483, baseline_percent=99.983%, BASE_JPEG_PSNR=34.364165 dB, ROI_JPEG_PSNR=нет dB, full_reconstructed_PSNR=20.757991 dB, ROI_reconstructed_PSNR=нет dB
|
||||
base240_1fps_q25_roi320_2fps_q35: BASE=240x135, 1.000 fps, Q25, base_frames=21, base=19.592167 kbit/s; ROI=320x180, 2.000 fps, Q35, roi_frames=42, roi=73.683467 kbit/s; total=93.275634 kbit/s, baseline_diff=12.991974, baseline_percent=116.183%, BASE_JPEG_PSNR=32.370927 dB, ROI_JPEG_PSNR=34.103745 dB, full_reconstructed_PSNR=19.657187 dB, ROI_reconstructed_PSNR=19.574377 dB
|
||||
base240_0_5fps_q25_roi320_2fps_q35: BASE=240x135, 0.500 fps, Q25, base_frames=11, base=10.150112 kbit/s; ROI=320x180, 2.000 fps, Q35, roi_frames=42, roi=73.683467 kbit/s; total=83.833579 kbit/s, baseline_diff=3.549919, baseline_percent=104.422%, BASE_JPEG_PSNR=32.474572 dB, ROI_JPEG_PSNR=34.103745 dB, full_reconstructed_PSNR=18.613817 dB, ROI_reconstructed_PSNR=19.574377 dB
|
||||
base160_1fps_q25_roi320_2fps_q35: BASE=160x90, 1.000 fps, Q25, base_frames=21, base=11.511525 kbit/s; ROI=320x180, 2.000 fps, Q35, roi_frames=42, roi=73.683467 kbit/s; total=85.194992 kbit/s, baseline_diff=4.911332, baseline_percent=106.117%, BASE_JPEG_PSNR=31.458773 dB, ROI_JPEG_PSNR=34.103745 dB, full_reconstructed_PSNR=19.701304 dB, ROI_reconstructed_PSNR=19.574377 dB
|
||||
base240_1fps_q25_roi448_1fps_q30: BASE=240x135, 1.000 fps, Q25, base_frames=21, base=19.592167 kbit/s; ROI=448x252, 1.000 fps, Q30, roi_frames=21, roi=56.932392 kbit/s; total=76.524559 kbit/s, baseline_diff=-3.759101, baseline_percent=95.318%, BASE_JPEG_PSNR=32.370927 dB, ROI_JPEG_PSNR=34.666932 dB, full_reconstructed_PSNR=19.098790 dB, ROI_reconstructed_PSNR=17.871555 dB
|
||||
base240_0_5fps_q25_roi448_1fps_q30: BASE=240x135, 0.500 fps, Q25, base_frames=11, base=10.150112 kbit/s; ROI=448x252, 1.000 fps, Q30, roi_frames=21, roi=56.932392 kbit/s; total=67.082504 kbit/s, baseline_diff=-13.201156, baseline_percent=83.557%, BASE_JPEG_PSNR=32.474572 dB, ROI_JPEG_PSNR=34.666932 dB, full_reconstructed_PSNR=18.113877 dB, ROI_reconstructed_PSNR=17.871555 dB
|
||||
base320_1fps_q30_roi320_1fps_q35: BASE=320x180, 1.000 fps, Q30, base_frames=21, base=33.862343 kbit/s; ROI=320x180, 1.000 fps, Q35, roi_frames=21, roi=36.738491 kbit/s; total=70.600835 kbit/s, baseline_diff=-9.682825, baseline_percent=87.939%, BASE_JPEG_PSNR=33.539230 dB, ROI_JPEG_PSNR=34.103514 dB, full_reconstructed_PSNR=19.121457 dB, ROI_reconstructed_PSNR=17.888190 dB
|
||||
base240_1fps_q20_roi320_1fps_q30: BASE=240x135, 1.000 fps, Q20, base_frames=21, base=17.180610 kbit/s; ROI=320x180, 1.000 fps, Q30, roi_frames=21, roi=33.306453 kbit/s; total=50.487063 kbit/s, baseline_diff=-29.796597, baseline_percent=62.886%, BASE_JPEG_PSNR=31.643522 dB, ROI_JPEG_PSNR=33.598412 dB, full_reconstructed_PSNR=19.107784 dB, ROI_reconstructed_PSNR=17.873870 dB
|
||||
base160_0_5fps_q20_roi320_2fps_q30: BASE=160x90, 0.500 fps, Q20, base_frames=11, base=5.323917 kbit/s; ROI=320x180, 2.000 fps, Q30, roi_frames=42, roi=66.820546 kbit/s; total=72.144462 kbit/s, baseline_diff=-8.139198, baseline_percent=89.862%, BASE_JPEG_PSNR=30.823518 dB, ROI_JPEG_PSNR=33.595566 dB, full_reconstructed_PSNR=18.664976 dB, ROI_reconstructed_PSNR=19.549559 dB
|
||||
|
||||
Профили до 80.283660 кбит/с:
|
||||
baseline_320x180_gray_2fps_q40: BASE=320x180, 2.000 fps, Q40, base_frames=42, base=80.270177 kbit/s; ROI=0x0, 0.000 fps, Q0, roi_frames=0, roi=0.000000 kbit/s; total=80.270177 kbit/s, baseline_diff=-0.013483, baseline_percent=99.983%, BASE_JPEG_PSNR=34.364165 dB, ROI_JPEG_PSNR=нет dB, full_reconstructed_PSNR=20.757991 dB, ROI_reconstructed_PSNR=нет dB
|
||||
base240_1fps_q25_roi448_1fps_q30: BASE=240x135, 1.000 fps, Q25, base_frames=21, base=19.592167 kbit/s; ROI=448x252, 1.000 fps, Q30, roi_frames=21, roi=56.932392 kbit/s; total=76.524559 kbit/s, baseline_diff=-3.759101, baseline_percent=95.318%, BASE_JPEG_PSNR=32.370927 dB, ROI_JPEG_PSNR=34.666932 dB, full_reconstructed_PSNR=19.098790 dB, ROI_reconstructed_PSNR=17.871555 dB
|
||||
base240_0_5fps_q25_roi448_1fps_q30: BASE=240x135, 0.500 fps, Q25, base_frames=11, base=10.150112 kbit/s; ROI=448x252, 1.000 fps, Q30, roi_frames=21, roi=56.932392 kbit/s; total=67.082504 kbit/s, baseline_diff=-13.201156, baseline_percent=83.557%, BASE_JPEG_PSNR=32.474572 dB, ROI_JPEG_PSNR=34.666932 dB, full_reconstructed_PSNR=18.113877 dB, ROI_reconstructed_PSNR=17.871555 dB
|
||||
base320_1fps_q30_roi320_1fps_q35: BASE=320x180, 1.000 fps, Q30, base_frames=21, base=33.862343 kbit/s; ROI=320x180, 1.000 fps, Q35, roi_frames=21, roi=36.738491 kbit/s; total=70.600835 kbit/s, baseline_diff=-9.682825, baseline_percent=87.939%, BASE_JPEG_PSNR=33.539230 dB, ROI_JPEG_PSNR=34.103514 dB, full_reconstructed_PSNR=19.121457 dB, ROI_reconstructed_PSNR=17.888190 dB
|
||||
base240_1fps_q20_roi320_1fps_q30: BASE=240x135, 1.000 fps, Q20, base_frames=21, base=17.180610 kbit/s; ROI=320x180, 1.000 fps, Q30, roi_frames=21, roi=33.306453 kbit/s; total=50.487063 kbit/s, baseline_diff=-29.796597, baseline_percent=62.886%, BASE_JPEG_PSNR=31.643522 dB, ROI_JPEG_PSNR=33.598412 dB, full_reconstructed_PSNR=19.107784 dB, ROI_reconstructed_PSNR=17.873870 dB
|
||||
base160_0_5fps_q20_roi320_2fps_q30: BASE=160x90, 0.500 fps, Q20, base_frames=11, base=5.323917 kbit/s; ROI=320x180, 2.000 fps, Q30, roi_frames=42, roi=66.820546 kbit/s; total=72.144462 kbit/s, baseline_diff=-8.139198, baseline_percent=89.862%, BASE_JPEG_PSNR=30.823518 dB, ROI_JPEG_PSNR=33.595566 dB, full_reconstructed_PSNR=18.664976 dB, ROI_reconstructed_PSNR=19.549559 dB
|
||||
|
||||
Профили до 100 кбит/с:
|
||||
baseline_320x180_gray_2fps_q40: BASE=320x180, 2.000 fps, Q40, base_frames=42, base=80.270177 kbit/s; ROI=0x0, 0.000 fps, Q0, roi_frames=0, roi=0.000000 kbit/s; total=80.270177 kbit/s, baseline_diff=-0.013483, baseline_percent=99.983%, BASE_JPEG_PSNR=34.364165 dB, ROI_JPEG_PSNR=нет dB, full_reconstructed_PSNR=20.757991 dB, ROI_reconstructed_PSNR=нет dB
|
||||
base240_1fps_q25_roi320_2fps_q35: BASE=240x135, 1.000 fps, Q25, base_frames=21, base=19.592167 kbit/s; ROI=320x180, 2.000 fps, Q35, roi_frames=42, roi=73.683467 kbit/s; total=93.275634 kbit/s, baseline_diff=12.991974, baseline_percent=116.183%, BASE_JPEG_PSNR=32.370927 dB, ROI_JPEG_PSNR=34.103745 dB, full_reconstructed_PSNR=19.657187 dB, ROI_reconstructed_PSNR=19.574377 dB
|
||||
base240_0_5fps_q25_roi320_2fps_q35: BASE=240x135, 0.500 fps, Q25, base_frames=11, base=10.150112 kbit/s; ROI=320x180, 2.000 fps, Q35, roi_frames=42, roi=73.683467 kbit/s; total=83.833579 kbit/s, baseline_diff=3.549919, baseline_percent=104.422%, BASE_JPEG_PSNR=32.474572 dB, ROI_JPEG_PSNR=34.103745 dB, full_reconstructed_PSNR=18.613817 dB, ROI_reconstructed_PSNR=19.574377 dB
|
||||
base160_1fps_q25_roi320_2fps_q35: BASE=160x90, 1.000 fps, Q25, base_frames=21, base=11.511525 kbit/s; ROI=320x180, 2.000 fps, Q35, roi_frames=42, roi=73.683467 kbit/s; total=85.194992 kbit/s, baseline_diff=4.911332, baseline_percent=106.117%, BASE_JPEG_PSNR=31.458773 dB, ROI_JPEG_PSNR=34.103745 dB, full_reconstructed_PSNR=19.701304 dB, ROI_reconstructed_PSNR=19.574377 dB
|
||||
base240_1fps_q25_roi448_1fps_q30: BASE=240x135, 1.000 fps, Q25, base_frames=21, base=19.592167 kbit/s; ROI=448x252, 1.000 fps, Q30, roi_frames=21, roi=56.932392 kbit/s; total=76.524559 kbit/s, baseline_diff=-3.759101, baseline_percent=95.318%, BASE_JPEG_PSNR=32.370927 dB, ROI_JPEG_PSNR=34.666932 dB, full_reconstructed_PSNR=19.098790 dB, ROI_reconstructed_PSNR=17.871555 dB
|
||||
base240_0_5fps_q25_roi448_1fps_q30: BASE=240x135, 0.500 fps, Q25, base_frames=11, base=10.150112 kbit/s; ROI=448x252, 1.000 fps, Q30, roi_frames=21, roi=56.932392 kbit/s; total=67.082504 kbit/s, baseline_diff=-13.201156, baseline_percent=83.557%, BASE_JPEG_PSNR=32.474572 dB, ROI_JPEG_PSNR=34.666932 dB, full_reconstructed_PSNR=18.113877 dB, ROI_reconstructed_PSNR=17.871555 dB
|
||||
base320_1fps_q30_roi320_1fps_q35: BASE=320x180, 1.000 fps, Q30, base_frames=21, base=33.862343 kbit/s; ROI=320x180, 1.000 fps, Q35, roi_frames=21, roi=36.738491 kbit/s; total=70.600835 kbit/s, baseline_diff=-9.682825, baseline_percent=87.939%, BASE_JPEG_PSNR=33.539230 dB, ROI_JPEG_PSNR=34.103514 dB, full_reconstructed_PSNR=19.121457 dB, ROI_reconstructed_PSNR=17.888190 dB
|
||||
base240_1fps_q20_roi320_1fps_q30: BASE=240x135, 1.000 fps, Q20, base_frames=21, base=17.180610 kbit/s; ROI=320x180, 1.000 fps, Q30, roi_frames=21, roi=33.306453 kbit/s; total=50.487063 kbit/s, baseline_diff=-29.796597, baseline_percent=62.886%, BASE_JPEG_PSNR=31.643522 dB, ROI_JPEG_PSNR=33.598412 dB, full_reconstructed_PSNR=19.107784 dB, ROI_reconstructed_PSNR=17.873870 dB
|
||||
base160_0_5fps_q20_roi320_2fps_q30: BASE=160x90, 0.500 fps, Q20, base_frames=11, base=5.323917 kbit/s; ROI=320x180, 2.000 fps, Q30, roi_frames=42, roi=66.820546 kbit/s; total=72.144462 kbit/s, baseline_diff=-8.139198, baseline_percent=89.862%, BASE_JPEG_PSNR=30.823518 dB, ROI_JPEG_PSNR=33.595566 dB, full_reconstructed_PSNR=18.664976 dB, ROI_reconstructed_PSNR=19.549559 dB
|
||||
|
||||
Профиль с максимальным ROI-разрешением до 100 кбит/с:
|
||||
base240_1fps_q25_roi448_1fps_q30: BASE=240x135, 1.000 fps, Q25, base_frames=21, base=19.592167 kbit/s; ROI=448x252, 1.000 fps, Q30, roi_frames=21, roi=56.932392 kbit/s; total=76.524559 kbit/s, baseline_diff=-3.759101, baseline_percent=95.318%, BASE_JPEG_PSNR=32.370927 dB, ROI_JPEG_PSNR=34.666932 dB, full_reconstructed_PSNR=19.098790 dB, ROI_reconstructed_PSNR=17.871555 dB
|
||||
|
||||
Профиль с максимальным ROI PSNR до 100 кбит/с:
|
||||
base240_1fps_q25_roi320_2fps_q35: BASE=240x135, 1.000 fps, Q25, base_frames=21, base=19.592167 kbit/s; ROI=320x180, 2.000 fps, Q35, roi_frames=42, roi=73.683467 kbit/s; total=93.275634 kbit/s, baseline_diff=12.991974, baseline_percent=116.183%, BASE_JPEG_PSNR=32.370927 dB, ROI_JPEG_PSNR=34.103745 dB, full_reconstructed_PSNR=19.657187 dB, ROI_reconstructed_PSNR=19.574377 dB
|
||||
|
||||
Профиль с максимальным full-frame PSNR до 100 кбит/с:
|
||||
baseline_320x180_gray_2fps_q40: BASE=320x180, 2.000 fps, Q40, base_frames=42, base=80.270177 kbit/s; ROI=0x0, 0.000 fps, Q0, roi_frames=0, roi=0.000000 kbit/s; total=80.270177 kbit/s, baseline_diff=-0.013483, baseline_percent=99.983%, BASE_JPEG_PSNR=34.364165 dB, ROI_JPEG_PSNR=нет dB, full_reconstructed_PSNR=20.757991 dB, ROI_reconstructed_PSNR=нет dB
|
||||
|
||||
Разделение битрейта BASE/ROI:
|
||||
baseline_320x180_gray_2fps_q40: BASE=80.270177 кбит/с, ROI=0.000000 кбит/с, total=80.270177 кбит/с.
|
||||
base240_1fps_q25_roi320_2fps_q35: BASE=19.592167 кбит/с, ROI=73.683467 кбит/с, total=93.275634 кбит/с.
|
||||
base240_0_5fps_q25_roi320_2fps_q35: BASE=10.150112 кбит/с, ROI=73.683467 кбит/с, total=83.833579 кбит/с.
|
||||
base160_1fps_q25_roi320_2fps_q35: BASE=11.511525 кбит/с, ROI=73.683467 кбит/с, total=85.194992 кбит/с.
|
||||
base240_1fps_q25_roi448_1fps_q30: BASE=19.592167 кбит/с, ROI=56.932392 кбит/с, total=76.524559 кбит/с.
|
||||
base240_0_5fps_q25_roi448_1fps_q30: BASE=10.150112 кбит/с, ROI=56.932392 кбит/с, total=67.082504 кбит/с.
|
||||
base320_1fps_q30_roi320_1fps_q35: BASE=33.862343 кбит/с, ROI=36.738491 кбит/с, total=70.600835 кбит/с.
|
||||
base240_1fps_q20_roi320_1fps_q30: BASE=17.180610 кбит/с, ROI=33.306453 кбит/с, total=50.487063 кбит/с.
|
||||
base160_0_5fps_q20_roi320_2fps_q30: BASE=5.323917 кбит/с, ROI=66.820546 кбит/с, total=72.144462 кбит/с.
|
||||
|
||||
Фиксированная ROI не гарантирует попадания препятствия в область интереса.
|
||||
PSNR не заменяет ручную оценку пригодности изображения.
|
||||
Нельзя автоматически объявлять профиль безопасным для управления ровером.
|
||||
Радиопротокол, радиозаголовки, CRC, FEC, фрагментация и повторные передачи не учтены.
|
||||
Сравнительное изображение: data\processed\lab027\lab027_candidate_profiles.png
|
||||
Следующий шаг: ручная оценка пользователем.
|
||||
5
data/processed/lab027c/lab027c_preview_profiles.csv
Normal file
@@ -0,0 +1,5 @@
|
||||
profile_name,update_mode,base_width,base_height,base_fps,base_quality,roi_width,roi_height,roi_fps,roi_quality,source_duration_s,selected_base_frames,selected_roi_frames,synchronized_composite_frames,total_base_bytes,total_roi_bytes,total_payload_bytes,mean_base_frame_bytes,mean_roi_frame_bytes,mean_composite_frame_bytes,p95_composite_frame_bytes,max_composite_frame_bytes,base_bitrate_kbps,roi_bitrate_kbps,total_payload_bitrate_kbps,timestamp_mismatch_count,frame_id_mismatch_count
|
||||
async_reference,asynchronous,240,135,1.000000,25,320,180,2.000000,35,20.766667,21,42,N/A,50858,191270,242128,2421.810,4554.048,5764.952,7477.600,7741,19.592167,73.683467,93.275634,N/A,N/A
|
||||
sync_base160_q25_roi320_q35,synchronous,160,90,2.000000,25,320,180,2.000000,35,20.766667,42,42,42,59958,191270,251228,1427.571,4554.048,5981.619,6519.900,6656,23.097785,73.683467,96.781252,0,0
|
||||
sync_base240_q20_roi320_q30,synchronous,240,135,2.000000,20,320,180,2.000000,30,20.766667,42,42,42,89696,173455,263151,2135.619,4129.881,6265.500,6780.400,6842,34.553836,66.820546,101.374382,0,0
|
||||
sync_base240_q25_roi320_q35,synchronous,240,135,2.000000,25,320,180,2.000000,35,20.766667,42,42,42,102283,191270,293553,2435.310,4554.048,6989.357,7572.350,7741,39.402761,73.683467,113.086228,0,0
|
||||
|
44
data/processed/lab027c/lab027c_preview_report.txt
Normal file
@@ -0,0 +1,44 @@
|
||||
Lab027C. Динамическое сравнение асинхронного и синхронного BASE + ROI
|
||||
|
||||
Цель: визуально сравнить ранее выбранный асинхронный профиль BASE 1 fps + ROI 2 fps с тремя синхронными профилями BASE 2 fps + ROI 2 fps.
|
||||
|
||||
Исходное видео: data\raw\lab026_rover_source.mp4
|
||||
Исходное разрешение: 1280x720
|
||||
Исходный FPS: 30.000000
|
||||
Число кадров: 623
|
||||
Длительность: 20.766667 с
|
||||
ROI: x=0.20...0.80, y=0.42...1.00; пиксели x=256...1024, y=302...720.
|
||||
|
||||
Результат ручной оценки предыдущей Lab027:
|
||||
- пользователь выбрал вариант B;
|
||||
- граница ROI не мешает;
|
||||
- обновление BASE один раз в секунду мешает;
|
||||
- рассинхронное движение BASE и ROI мешает;
|
||||
- принято решение сравнить с синхронным обновлением 2 fps.
|
||||
|
||||
async_reference: BASE и ROI обновляются независимо. Единого logical_frame_id нет; используются отдельные BASE ID и ROI ID. Mismatch для этого режима неприменим.
|
||||
Синхронные профили: BASE и ROI формируются из одного source frame, получают единые composite ID и timestamp и атомарно заменяют отображаемый составной кадр.
|
||||
|
||||
Фактические результаты четырёх профилей:
|
||||
async_reference: mode=asynchronous, BASE=240x135, 1.000 fps, Q25, base_frames=21, base_bytes=50858, base_bitrate=19.592167 kbit/s; ROI=320x180, 2.000 fps, Q35, roi_frames=42, roi_bytes=191270, roi_bitrate=73.683467 kbit/s; total_bytes=242128, total_bitrate=93.275634 kbit/s, sync_frames=N/A, timestamp_mismatch=N/A, frame_id_mismatch=N/A
|
||||
sync_base160_q25_roi320_q35: mode=synchronous, BASE=160x90, 2.000 fps, Q25, base_frames=42, base_bytes=59958, base_bitrate=23.097785 kbit/s; ROI=320x180, 2.000 fps, Q35, roi_frames=42, roi_bytes=191270, roi_bitrate=73.683467 kbit/s; total_bytes=251228, total_bitrate=96.781252 kbit/s, sync_frames=42, timestamp_mismatch=0, frame_id_mismatch=0
|
||||
sync_base240_q20_roi320_q30: mode=synchronous, BASE=240x135, 2.000 fps, Q20, base_frames=42, base_bytes=89696, base_bitrate=34.553836 kbit/s; ROI=320x180, 2.000 fps, Q30, roi_frames=42, roi_bytes=173455, roi_bitrate=66.820546 kbit/s; total_bytes=263151, total_bitrate=101.374382 kbit/s, sync_frames=42, timestamp_mismatch=0, frame_id_mismatch=0
|
||||
sync_base240_q25_roi320_q35: mode=synchronous, BASE=240x135, 2.000 fps, Q25, base_frames=42, base_bytes=102283, base_bitrate=39.402761 kbit/s; ROI=320x180, 2.000 fps, Q35, roi_frames=42, roi_bytes=191270, roi_bitrate=73.683467 kbit/s; total_bytes=293553, total_bitrate=113.086228 kbit/s, sync_frames=42, timestamp_mismatch=0, frame_id_mismatch=0
|
||||
|
||||
Количество обновлений:
|
||||
async_reference: BASE=21, ROI=42, synchronized=N/A.
|
||||
sync_base160_q25_roi320_q35: BASE=42, ROI=42, synchronized=42.
|
||||
sync_base240_q20_roi320_q30: BASE=42, ROI=42, synchronized=42.
|
||||
sync_base240_q25_roi320_q35: BASE=42, ROI=42, synchronized=42.
|
||||
|
||||
Mismatch-проверка:
|
||||
async_reference: timestamp=N/A, frame_id=N/A.
|
||||
sync_base160_q25_roi320_q35: timestamp=0, frame_id=0.
|
||||
sync_base240_q20_roi320_q30: timestamp=0, frame_id=0.
|
||||
sync_base240_q25_roi320_q35: timestamp=0, frame_id=0.
|
||||
|
||||
Preview: data\raw\lab027c_previews\lab027c_synchronous_roi_preview.mp4
|
||||
Формат: MP4/mp4v.
|
||||
Радиопротокол, CRC, FEC, фрагментация и служебный трафик пока не учитываются.
|
||||
Программа не выбирает лучший профиль и не объявляет режим безопасным автоматически.
|
||||
Следующий шаг: ручной выбор пользователем после просмотра preview-видео.
|
||||
5
data/processed/lab027d/lab027d_profiles.csv
Normal file
@@ -0,0 +1,5 @@
|
||||
profile_name,base_width,base_height,fps,base_quality,roi_width,roi_height,roi_quality,source_duration_s,selected_base_frames,selected_roi_frames,synchronized_composite_frames,total_base_bytes,total_roi_bytes,total_payload_bytes,mean_base_frame_bytes,mean_roi_frame_bytes,mean_composite_frame_bytes,p95_composite_frame_bytes,max_composite_frame_bytes,base_bitrate_kbps,roi_bitrate_kbps,total_payload_bitrate_kbps,channel_rate_fec_2_3_kbps,channel_rate_fec_1_2_kbps,timestamp_mismatch_count,frame_id_mismatch_count
|
||||
sync_2fps_base_q20_roi_q30,240,135,2.000000,20,320,180,30,20.766667,42,42,42,89696,173455,263151,2135.619,4129.881,6265.500,6780.400,6842,34.553836,66.820546,101.374382,167.267730,223.023640,0,0
|
||||
sync_3fps_base_q20_roi_q30,240,135,3.000000,20,320,180,30,20.766667,63,63,63,133076,249436,382512,2112.317,3959.302,6071.619,6640.800,6842,51.265233,96.090915,147.356148,243.137644,324.183525,0,0
|
||||
sync_3fps_base_q23_roi_q33,240,135,3.000000,23,320,180,33,20.766667,63,63,63,144649,264895,409544,2296.016,4204.683,6500.698,7125.400,7383,55.723531,102.046228,157.769759,260.320103,347.093470,0,0
|
||||
sync_3fps_base_q25_roi_q35,240,135,3.000000,25,320,180,35,20.766667,63,63,63,151516,274486,426002,2405.016,4356.921,6761.937,7421.700,7741,58.368925,105.740995,164.109920,270.781368,361.041823,0,0
|
||||
|
37
data/processed/lab027d/lab027d_report.txt
Normal file
@@ -0,0 +1,37 @@
|
||||
Lab027D. Синхронный BASE + ROI при 2 и 3 fps
|
||||
|
||||
Цель: сравнить текущий синхронный профиль 2 fps с тремя профилями 3 fps при разных JPEG Quality.
|
||||
|
||||
Результат ручной оценки Lab027C:
|
||||
- лучший из показанных вариантов — нижний левый;
|
||||
- синхронизация BASE и ROI устранила рассогласование;
|
||||
- 2 fps недостаточно;
|
||||
- BASE 160x90 слишком грубый;
|
||||
- BASE 240x135 Q20 немного недостаточен по качеству.
|
||||
|
||||
Причина повышения FPS: ручная оценка показала, что синхронное обновление устраняет рассогласование, но частота 2 fps недостаточна для динамической сцены.
|
||||
|
||||
Исходное видео: data\raw\lab026_rover_source.mp4
|
||||
Разрешение: 1280x720
|
||||
FPS: 30.000000
|
||||
Кадров: 623
|
||||
Длительность: 20.766667 с
|
||||
ROI: x=0.20...0.80, y=0.42...1.00; пиксели x=256...1024, y=302...720.
|
||||
|
||||
Все профили синхронные: BASE и ROI формируются из одного source frame, получают единый timestamp и composite frame ID и публикуются атомарно.
|
||||
|
||||
Фактические результаты:
|
||||
sync_2fps_base_q20_roi_q30: 2.000 fps; BASE=240x135, Q20, frames=42, bytes=89696, bitrate=34.553836 kbit/s; ROI=320x180, Q30, frames=42, bytes=173455, bitrate=66.820546 kbit/s; total_bytes=263151, payload=101.374382 kbit/s; channel FEC 2/3=167.267730 kbit/s; channel FEC 1/2=223.023640 kbit/s; sync_frames=42; timestamp_mismatch=0; frame_id_mismatch=0
|
||||
sync_3fps_base_q20_roi_q30: 3.000 fps; BASE=240x135, Q20, frames=63, bytes=133076, bitrate=51.265233 kbit/s; ROI=320x180, Q30, frames=63, bytes=249436, bitrate=96.090915 kbit/s; total_bytes=382512, payload=147.356148 kbit/s; channel FEC 2/3=243.137644 kbit/s; channel FEC 1/2=324.183525 kbit/s; sync_frames=63; timestamp_mismatch=0; frame_id_mismatch=0
|
||||
sync_3fps_base_q23_roi_q33: 3.000 fps; BASE=240x135, Q23, frames=63, bytes=144649, bitrate=55.723531 kbit/s; ROI=320x180, Q33, frames=63, bytes=264895, bitrate=102.046228 kbit/s; total_bytes=409544, payload=157.769759 kbit/s; channel FEC 2/3=260.320103 kbit/s; channel FEC 1/2=347.093470 kbit/s; sync_frames=63; timestamp_mismatch=0; frame_id_mismatch=0
|
||||
sync_3fps_base_q25_roi_q35: 3.000 fps; BASE=240x135, Q25, frames=63, bytes=151516, bitrate=58.368925 kbit/s; ROI=320x180, Q35, frames=63, bytes=274486, bitrate=105.740995 kbit/s; total_bytes=426002, payload=164.109920 kbit/s; channel FEC 2/3=270.781368 kbit/s; channel FEC 1/2=361.041823 kbit/s; sync_frames=63; timestamp_mismatch=0; frame_id_mismatch=0
|
||||
|
||||
Оценки channel rate иллюстративны: к payload добавлено 10% служебных данных, затем применена оценка FEC 2/3 или FEC 1/2.
|
||||
Эти значения не являются окончательной архитектурой радиоканала.
|
||||
|
||||
Preview: data\raw\lab027d_previews\lab027d_fps_quality_preview.mp4
|
||||
Формат: MP4/mp4v.
|
||||
|
||||
Предупреждение: оценки пока не включают окончательную модуляцию, полосу сигнала, интерливинг, повторы и команды управления.
|
||||
Программа не выбирает лучший профиль автоматически.
|
||||
Следующий шаг: ручной выбор пользователя после просмотра preview-видео.
|
||||
2
data/processed/lab027e/lab027e_profile.csv
Normal file
@@ -0,0 +1,2 @@
|
||||
profile_name,playback_speed_factor,source_width,source_height,source_fps,source_frames,source_duration_s,output_width,output_height,output_fps,output_frames,output_duration_s,composite_fps,base_width,base_height,base_quality,roi_width,roi_height,roi_quality,selected_composite_frames,total_base_bytes,total_roi_bytes,total_payload_bytes,mean_base_frame_bytes,mean_roi_frame_bytes,mean_composite_frame_bytes,p95_composite_frame_bytes,max_composite_frame_bytes,base_bitrate_kbps,roi_bitrate_kbps,total_payload_bitrate_kbps,channel_rate_fec_2_3_kbps,channel_rate_fec_1_2_kbps,timestamp_mismatch_count,frame_id_mismatch_count
|
||||
sync_3fps_base240_q23_roi320_q33_speed_0_5x,0.500000,1280,720,30.000000,623,20.766667,1280,720,30.000000,1246,41.533333,3.000000,240,135,23,320,180,33,125,286715,524962,811677,2293.720,4199.696,6493.416,7125.400,7890,55.226003,101.116276,156.342279,257.964761,343.953014,0,0
|
||||
|
50
data/processed/lab027e/lab027e_report.txt
Normal file
@@ -0,0 +1,50 @@
|
||||
Lab027E. Операторское preview при playback 0.5x
|
||||
|
||||
Результаты ручной оценки Lab027D:
|
||||
- 3 fps достаточно на исходном быстром видео;
|
||||
- Q20/Q30 приемлем;
|
||||
- Q23/Q33 приемлем;
|
||||
- разница Q23/Q33 и Q25/Q35 практически отсутствует.
|
||||
|
||||
Предварительно выбран профиль Q23/Q33.
|
||||
От Q25/Q35 предварительно отказались, поскольку визуальный выигрыш почти отсутствует.
|
||||
Профиль не считается окончательно утверждённым до ручного просмотра этого preview.
|
||||
|
||||
Исходная сцена замедлена для имитации более медленного движения ровера и оценки удобства управления.
|
||||
Коэффициент playback: 0.500x.
|
||||
Предупреждение: реальная скорость исходного автомобиля неизвестна.
|
||||
|
||||
Исходное видео:
|
||||
- путь: data\raw\lab026_rover_source.mp4;
|
||||
- разрешение: 1280x720;
|
||||
- FPS: 30.000000;
|
||||
- кадров: 623;
|
||||
- длительность: 20.766667 с.
|
||||
|
||||
Выходное preview:
|
||||
- разрешение: 1280x720;
|
||||
- FPS: 30.000000;
|
||||
- кадров: 1246;
|
||||
- длительность: 41.533333 с;
|
||||
- composite FPS: 3.000000.
|
||||
|
||||
Профиль:
|
||||
- BASE 240x135 Q23;
|
||||
- ROI 320x180 Q33;
|
||||
- составных обновлений: 125.
|
||||
|
||||
Фактические скорости:
|
||||
- BASE: 55.226003 kbit/s;
|
||||
- ROI: 101.116276 kbit/s;
|
||||
- total payload: 156.342279 kbit/s;
|
||||
- channel rate FEC 2/3: 257.964761 kbit/s;
|
||||
- channel rate FEC 1/2: 343.953014 kbit/s.
|
||||
|
||||
Оценки канальной скорости иллюстративны и не являются окончательной архитектурой радиоканала.
|
||||
Mismatch-проверка:
|
||||
- timestamp mismatch: 0;
|
||||
- frame ID mismatch: 0.
|
||||
|
||||
Preview: data\raw\lab027e_previews\lab027e_operator_view_0_5x.mp4
|
||||
Формат: MP4/mp4v.
|
||||
Следующий шаг: ручная оценка удобства управления пользователем.
|
||||
BIN
data/processed/lab028/lab028_overhead_efficiency.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
6
data/processed/lab028/lab028_packet_payload_results.csv
Normal file
@@ -0,0 +1,6 @@
|
||||
max_payload_bytes,composite_frames,mean_base_packets_per_frame,mean_roi_packets_per_frame,mean_total_packets_per_frame,max_total_packets_per_frame,packets_per_second,jpeg_payload_bytes,jpeg_payload_bitrate_kbps,header_bytes_per_second,header_bitrate_kbps,wire_bytes,wire_bitrate_kbps,service_data_percent,efficiency_percent,mean_wire_packet_bytes,p95_wire_packet_bytes,max_wire_packet_bytes
|
||||
64,63,36.428571,66.174603,102.603175,117,311.268058,409544,157.769759,9960.577849,79.684623,616392,237.454382,33.557866,66.442134,95.357673,96.000000,96
|
||||
128,63,18.428571,33.269841,51.698413,59,156.837881,409544,157.769759,5018.812199,40.150498,513768,197.920257,20.286199,79.713801,157.742708,160.000000,160
|
||||
256,63,9.428571,16.920635,26.349206,30,79.935795,409544,157.769759,2557.945425,20.463563,462664,178.233323,11.481334,88.518666,278.713253,288.000000,288
|
||||
512,63,5.000000,8.682540,13.682540,15,41.508828,409544,157.769759,1328.282504,10.626260,437128,168.396019,6.310280,93.689720,507.109049,544.000000,544
|
||||
1024,63,3.000000,4.587302,7.587302,8,23.017657,409544,157.769759,736.565008,5.892520,424840,163.662279,3.600414,96.399586,888.786611,1056.000000,1056
|
||||
|
BIN
data/processed/lab028/lab028_packets_wire_bitrate.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
52
data/processed/lab028/lab028_report.txt
Normal file
@@ -0,0 +1,52 @@
|
||||
Lab028. Пакетирование синхронного BASE + ROI
|
||||
|
||||
Профиль и исходные данные
|
||||
- Видео: data\raw\lab026_rover_source.mp4
|
||||
- Источник: 1280x720, 30.000000 fps, 623 кадров, 20.766667 с.
|
||||
- ROI: x=0.20...0.80, y=0.42...1.00; пиксели x=256...1024, y=302...720.
|
||||
- Синхронные составные кадры: 63 при 3.000 fps.
|
||||
- BASE: 240x135, grayscale JPEG Q23; средний размер 2296.016 B, min/max 2122/2476 B.
|
||||
- ROI: 320x180, grayscale JPEG Q33; средний размер 4204.683 B, min/max 3457/4938 B.
|
||||
- BASE и ROI формируются из одного source frame и имеют общий composite_frame_id.
|
||||
|
||||
Бинарный заголовок
|
||||
- struct format: !4sBBHIHHHHIII
|
||||
- Размер: 32 байта; network byte order; padding нет.
|
||||
- Layout: magic[4] @0, version:u8 @4, object_type:u8 @5, header_size:u16 @6, composite_frame_id:u32 @8, fragment_index:u16 @12, fragment_count:u16 @14, payload_length:u16 @16, flags:u16 @18, jpeg_size:u32 @20, object_crc32:u32 @24, packet_crc32:u32 @28.
|
||||
- object_type: 1=BASE, 2=ROI; flags зарезервирован и равен 0.
|
||||
|
||||
CRC32
|
||||
- object_crc32 = zlib.crc32(полный JPEG) & 0xFFFFFFFF; значение помещается во все фрагменты объекта и проверяется после полной сборки.
|
||||
- packet_crc32: сначала поле packet_crc32 заголовка обнуляется, затем CRC считается как zlib.crc32(header_with_zero_crc + payload) & 0xFFFFFFFF.
|
||||
- Таким образом packet CRC защищает все поля заголовка и payload.
|
||||
|
||||
Функциональные проверки
|
||||
- PASS header_serialization_round_trip: fixed 32-byte header round trip is exact
|
||||
- PASS ordered_lossless_transfer: BASE and ROI match original JPEG bytes
|
||||
- PASS random_packet_order: arbitrary packet order reconstructed correctly
|
||||
- PASS exact_duplicate_packets: 2 exact duplicates ignored
|
||||
- PASS packet_crc_corruption: payload bit flip rejected; composite not emitted
|
||||
- PASS missing_fragment: ROI fragment 10 reported missing
|
||||
- PASS object_crc_corruption: valid packet CRCs still failed full-object CRC
|
||||
- PASS adjacent_frame_isolation: two interleaved frame IDs remained independent
|
||||
- PASS base_without_roi_atomicity: complete BASE retained while composite stayed unpublished
|
||||
|
||||
Результаты размеров packet payload
|
||||
payload | BASE pkt/frame | ROI pkt/frame | all pkt/frame | max pkt/frame | pkt/s | JPEG kbit/s | headers B/s | wire kbit/s | service % | efficiency % | packet mean/p95/max
|
||||
-------:|---------------:|--------------:|--------------:|--------------:|------:|------------:|------------:|------------:|----------:|-------------:|--------------------:
|
||||
64 | 36.429 | 66.175 | 102.603 | 117 | 311.268 | 157.770 | 9960.578 | 237.454 | 33.558 | 66.442 | 95.4/96.0/96
|
||||
128 | 18.429 | 33.270 | 51.698 | 59 | 156.838 | 157.770 | 5018.812 | 197.920 | 20.286 | 79.714 | 157.7/160.0/160
|
||||
256 | 9.429 | 16.921 | 26.349 | 30 | 79.936 | 157.770 | 2557.945 | 178.233 | 11.481 | 88.519 | 278.7/288.0/288
|
||||
512 | 5.000 | 8.683 | 13.683 | 15 | 41.509 | 157.770 | 1328.283 | 168.396 | 6.310 | 93.690 | 507.1/544.0/544
|
||||
1024 | 3.000 | 4.587 | 7.587 | 8 | 23.018 | 157.770 | 736.565 | 163.662 | 3.600 | 96.400 | 888.8/1056.0/1056
|
||||
|
||||
JPEG payload bitrate одинаков для всех строк, потому что исходные JPEG не меняются при выборе размера фрагмента.
|
||||
Wire bitrate включает только JPEG payload и 32-байтные заголовки каждого пакета.
|
||||
Wire bitrate пока НЕ включает FEC, преамбулу, синхронизацию, модуляцию, интервалы, повторные передачи, команды управления и телеметрию.
|
||||
Лучший размер packet payload автоматически не выбирается: таблица показывает только транспортный компромисс.
|
||||
|
||||
Артефакты
|
||||
- CSV: data\processed\lab028\lab028_packet_payload_results.csv
|
||||
- Overhead/efficiency: data\processed\lab028\lab028_overhead_efficiency.png
|
||||
- Packet count/wire bitrate: data\processed\lab028\lab028_packets_wire_bitrate.png
|
||||
- Промежуточные JPEG и пакеты сохранялись только в памяти; бинарные дампы не создавались.
|
||||
405
docs/video_compression_research_lab026_lab027.md
Normal file
@@ -0,0 +1,405 @@
|
||||
# Исследование сжатия видеопотока в Lab026–Lab027
|
||||
|
||||
## 1. Назначение исследований
|
||||
|
||||
Lab026, Lab026B и Lab027 исследуют способы уменьшения полезной нагрузки
|
||||
видеоканала для системы управления ровером через ретранслятор.
|
||||
|
||||
На рассматриваемом этапе измеряется только полезная нагрузка,
|
||||
формируемая средствами покадрового кодирования изображения. Исследования
|
||||
не включают радиопередачу и не определяют профиль как автоматически
|
||||
безопасный для управления.
|
||||
|
||||
Последовательность исследований:
|
||||
|
||||
```text
|
||||
Lab026
|
||||
|
|
||||
+-- разрешение кадра
|
||||
+-- частота кадров
|
||||
+-- color / grayscale
|
||||
+-- JPEG Quality
|
||||
|
|
||||
v
|
||||
Контрольный профиль: 320x180, grayscale, 2 fps, JPEG Q40
|
||||
|
|
||||
+--> Lab026B: 4-битная яркость и индексированный цвет
|
||||
|
|
||||
+--> Lab027: раздельная передача общего кадра BASE и области ROI
|
||||
```
|
||||
|
||||
## 2. Исходное видео
|
||||
|
||||
Во всех трёх лабораторных использован один файл:
|
||||
|
||||
```text
|
||||
data/raw/lab026_rover_source.mp4
|
||||
```
|
||||
|
||||
| Параметр | Значение |
|
||||
|---|---:|
|
||||
| Размер файла | 7 790 985 байт |
|
||||
| Разрешение | 1280x720 |
|
||||
| Частота кадров | 30.000000 кадр/с |
|
||||
| Число кадров | 623 |
|
||||
| Длительность | 20.766667 с |
|
||||
| Приблизительный файловый битрейт | 3001.343 кбит/с |
|
||||
|
||||
## 3. Метод покадрового JPEG-сжатия
|
||||
|
||||
Lab026 и JPEG-режимы последующих лабораторных используют независимое
|
||||
покадровое сжатие:
|
||||
|
||||
```text
|
||||
исходный кадр
|
||||
|
|
||||
+--> изменение разрешения
|
||||
|
|
||||
+--> преобразование color / grayscale по параметрам профиля
|
||||
|
|
||||
+--> cv2.imencode(..., JPEG Quality)
|
||||
|
|
||||
+--> размер JPEG в байтах
|
||||
|
|
||||
+--> cv2.imdecode(...)
|
||||
|
|
||||
+--> расчёт PSNR
|
||||
```
|
||||
|
||||
JPEG кодируется и декодируется в памяти. Отдельные JPEG-файлы на диск
|
||||
не сохраняются. Межкадровое предсказание не используется.
|
||||
|
||||
Кадры для заданной частоты выбираются по временной шкале. В общем виде:
|
||||
|
||||
```text
|
||||
frame_time = frame_index / source_fps
|
||||
|
||||
кадр выбирается, если:
|
||||
frame_time + 1e-9 >= next_sample_time
|
||||
|
||||
после выбора:
|
||||
next_sample_time += 1 / target_fps
|
||||
```
|
||||
|
||||
## 4. Lab026: разрешение, FPS, grayscale и JPEG Quality
|
||||
|
||||
### 4.1. Изменение FPS
|
||||
|
||||
Условия серии: 640x360, grayscale, JPEG Q50.
|
||||
|
||||
| Целевой FPS | Выбрано кадров | Payload, кбит/с | PSNR, дБ |
|
||||
|---:|---:|---:|---:|
|
||||
| 30 | 623 | 3936.913 | 37.350 |
|
||||
| 10 | 208 | 1402.877 | 36.370 |
|
||||
| 5 | 104 | 702.043 | 36.353 |
|
||||
| 2 | 42 | 284.084 | 36.320 |
|
||||
| 1 | 21 | 142.273 | 36.288 |
|
||||
| 0.5 | 11 | 73.821 | 36.372 |
|
||||
| 0.2 | 5 | 35.247 | 35.860 |
|
||||
|
||||
### 4.2. Изменение разрешения
|
||||
|
||||
Условия серии: 5 кадр/с, grayscale, JPEG Q50.
|
||||
|
||||
| Разрешение | Выбрано кадров | Payload, кбит/с | PSNR, дБ |
|
||||
|---|---:|---:|---:|
|
||||
| 1280x720 | 104 | 2121.333 | 39.608 |
|
||||
| 960x540 | 104 | 1314.376 | 38.632 |
|
||||
| 640x360 | 104 | 702.043 | 36.353 |
|
||||
| 480x270 | 104 | 431.824 | 36.172 |
|
||||
| 320x180 | 104 | 227.294 | 35.069 |
|
||||
| 160x90 | 104 | 80.918 | 33.586 |
|
||||
|
||||
### 4.3. Color, grayscale и JPEG Quality
|
||||
|
||||
Условия серии: 640x360, 5 кадр/с.
|
||||
|
||||
| Режим | JPEG Quality | Payload, кбит/с | PSNR, дБ |
|
||||
|---|---:|---:|---:|
|
||||
| color | 90 | 2113.639 | 39.694 |
|
||||
| color | 70 | 1117.677 | 36.029 |
|
||||
| color | 50 | 823.307 | 34.439 |
|
||||
| color | 30 | 605.629 | 32.766 |
|
||||
| color | 20 | 471.731 | 31.299 |
|
||||
| grayscale | 90 | 1809.440 | 42.575 |
|
||||
| grayscale | 70 | 957.219 | 38.032 |
|
||||
| grayscale | 50 | 702.043 | 36.353 |
|
||||
| grayscale | 30 | 511.658 | 34.702 |
|
||||
| grayscale | 20 | 392.150 | 33.304 |
|
||||
|
||||
### 4.4. Контрольный профиль
|
||||
|
||||
В качестве контрольного профиля принят:
|
||||
|
||||
| Параметр | Значение |
|
||||
|---|---:|
|
||||
| Разрешение | 320x180 |
|
||||
| Цветовой режим | grayscale |
|
||||
| Частота | 2 кадр/с |
|
||||
| JPEG Quality | 40 |
|
||||
| Выбрано кадров | 42 |
|
||||
| Payload bitrate | 80.283660 кбит/с |
|
||||
| Средний PSNR | 34.362114 дБ |
|
||||
|
||||
Этот профиль используется как baseline в Lab026B и как контрольная
|
||||
точка для Lab027.
|
||||
|
||||
## 5. Lab026B: глубина 4 бита на пиксель
|
||||
|
||||
### 5.1. Исследованные представления
|
||||
|
||||
Под 4-битной яркостью понимается индекс `0...15`, то есть 16 уровней
|
||||
серого. Восстановленное значение формируется по тому же индексу в
|
||||
диапазоне `0...255`.
|
||||
|
||||
Под 4-битным индексированным цветом понимается один индекс `0...15`
|
||||
на пиксель и палитра не более 16 RGB-цветов на кадр. Это не RGB444:
|
||||
RGB444 использует по 4 бита на каждый из трёх каналов, то есть 12 бит
|
||||
на пиксель.
|
||||
|
||||
Исследованы пять режимов:
|
||||
|
||||
| Режим | Представление |
|
||||
|---|---|
|
||||
| `gray8_jpeg` | Контрольный grayscale JPEG Q40 |
|
||||
| `gray4_jpeg` | 16 уровней серого, восстановление в `uint8`, JPEG Q40 |
|
||||
| `gray4_packed_zlib` | Два 4-битных индекса в байте, zlib level 9, заголовок 16 байт |
|
||||
| `color16_png` | До 16 цветов, без дизеринга, индексированный PNG |
|
||||
| `color16_packed_zlib` | 4-битные индексы, zlib level 9, заголовок 16 байт, палитра 48 байт |
|
||||
|
||||
### 5.2. Результаты при 320x180 и 2 кадр/с
|
||||
|
||||
| Режим | Payload, кбит/с | Средний PSNR, дБ |
|
||||
|---|---:|---:|
|
||||
| `gray8_jpeg` | 80.283660 | 34.362114 |
|
||||
| `gray4_jpeg` | 88.417849 | 32.588941 |
|
||||
| `gray4_packed_zlib` | 169.916148 | 34.397012 |
|
||||
| `color16_png` | 143.121669 | 25.384724 |
|
||||
| `color16_packed_zlib` | 140.568347 | 25.384724 |
|
||||
|
||||
### 5.3. Зафиксированный отрицательный результат
|
||||
|
||||
В Lab026B уменьшение глубины до 4 бит не позволило повысить разрешение
|
||||
кадра в пределах 100 кбит/с.
|
||||
|
||||
До 100 кбит/с прошли только два профиля:
|
||||
|
||||
| Профиль | Разрешение | Payload, кбит/с | Средний PSNR, дБ |
|
||||
|---|---|---:|---:|
|
||||
| `320x180_gray8_jpeg_2fps` | 320x180 | 80.283660 | 34.362114 |
|
||||
| `320x180_gray4_jpeg_2fps` | 320x180 | 88.417849 | 32.588941 |
|
||||
|
||||
Профилей с разрешением больше 320x180 и payload не более
|
||||
80.283660 кбит/с не получено. Цветных `color16`-профилей до
|
||||
100 кбит/с также не получено.
|
||||
|
||||
## 6. Lab027: многомасштабная передача BASE + ROI
|
||||
|
||||
### 6.1. Принцип
|
||||
|
||||
В Lab027 один профиль состоит из двух независимых JPEG-потоков:
|
||||
|
||||
```text
|
||||
исходный полноразмерный кадр
|
||||
|
|
||||
+--> BASE
|
||||
| полный grayscale-кадр
|
||||
| собственные разрешение, FPS и JPEG Quality
|
||||
|
|
||||
+--> ROI
|
||||
вырезка из исходного кадра
|
||||
собственные разрешение, FPS и JPEG Quality
|
||||
|
||||
приёмная реконструкция 640x360:
|
||||
|
||||
декодированный BASE
|
||||
|
|
||||
+--> масштабирование до 640x360
|
||||
|
|
||||
+--> наложение последнего декодированного ROI
|
||||
в геометрическое положение области интереса
|
||||
```
|
||||
|
||||
BASE и ROI обновляются независимо по временной шкале. Между
|
||||
обновлениями удерживается последнее декодированное состояние каждого
|
||||
потока.
|
||||
|
||||
### 6.2. Фиксированная ROI
|
||||
|
||||
Нормализованные координаты:
|
||||
|
||||
```text
|
||||
x_min = 0.20
|
||||
x_max = 0.80
|
||||
y_min = 0.42
|
||||
y_max = 1.00
|
||||
```
|
||||
|
||||
Для исходного кадра 1280x720 используются координаты:
|
||||
|
||||
```text
|
||||
x = 256...1024
|
||||
y = 302...720
|
||||
```
|
||||
|
||||
Область занимает центральные 60% ширины и нижние 58% высоты кадра.
|
||||
ROI фиксирована и не использует автоматическое распознавание дороги,
|
||||
нейронные сети или детекторы объектов.
|
||||
|
||||
### 6.3. Результаты ROI-профилей
|
||||
|
||||
| Профиль | BASE | ROI | BASE, кбит/с | ROI, кбит/с | Total, кбит/с | Full PSNR, дБ | ROI PSNR, дБ |
|
||||
|---|---|---|---:|---:|---:|---:|---:|
|
||||
| `base240_1fps_q25_roi320_2fps_q35` | 240x135, 1 FPS, Q25 | 320x180, 2 FPS, Q35 | 19.592167 | 73.683467 | 93.275634 | 19.657187 | 19.574377 |
|
||||
| `base240_0_5fps_q25_roi320_2fps_q35` | 240x135, 0.5 FPS, Q25 | 320x180, 2 FPS, Q35 | 10.150112 | 73.683467 | 83.833579 | 18.613817 | 19.574377 |
|
||||
| `base160_1fps_q25_roi320_2fps_q35` | 160x90, 1 FPS, Q25 | 320x180, 2 FPS, Q35 | 11.511525 | 73.683467 | 85.194992 | 19.701304 | 19.574377 |
|
||||
| `base240_1fps_q25_roi448_1fps_q30` | 240x135, 1 FPS, Q25 | 448x252, 1 FPS, Q30 | 19.592167 | 56.932392 | 76.524559 | 19.098790 | 17.871555 |
|
||||
| `base240_0_5fps_q25_roi448_1fps_q30` | 240x135, 0.5 FPS, Q25 | 448x252, 1 FPS, Q30 | 10.150112 | 56.932392 | 67.082504 | 18.113877 | 17.871555 |
|
||||
| `base320_1fps_q30_roi320_1fps_q35` | 320x180, 1 FPS, Q30 | 320x180, 1 FPS, Q35 | 33.862343 | 36.738491 | 70.600835 | 19.121457 | 17.888190 |
|
||||
| `base240_1fps_q20_roi320_1fps_q30` | 240x135, 1 FPS, Q20 | 320x180, 1 FPS, Q30 | 17.180610 | 33.306453 | 50.487063 | 19.107784 | 17.873870 |
|
||||
| `base160_0_5fps_q20_roi320_2fps_q30` | 160x90, 0.5 FPS, Q20 | 320x180, 2 FPS, Q30 | 5.323917 | 66.820546 | 72.144462 | 18.664976 | 19.549559 |
|
||||
|
||||
Контрольный baseline при повторении в Lab027 составил
|
||||
80.270177 кбит/с. Контрольное значение из Lab026, используемое для
|
||||
сравнения, равно 80.283660 кбит/с.
|
||||
|
||||
### 6.4. Основной режим
|
||||
|
||||
В качестве основного режима в этом описании зафиксирован профиль:
|
||||
|
||||
```text
|
||||
base160_1fps_q25_roi320_2fps_q35
|
||||
```
|
||||
|
||||
| Компонент | Параметры |
|
||||
|---|---|
|
||||
| BASE | 160x90, 1 кадр/с, JPEG Q25 |
|
||||
| ROI | 320x180, 2 кадр/с, JPEG Q35 |
|
||||
| BASE bitrate | 11.511525 кбит/с |
|
||||
| ROI bitrate | 73.683467 кбит/с |
|
||||
| Total payload bitrate | 85.194992 кбит/с |
|
||||
| Mean reconstructed full PSNR | 19.701304 дБ |
|
||||
| Mean reconstructed ROI PSNR | 19.574377 дБ |
|
||||
|
||||
### 6.5. Аварийный режим
|
||||
|
||||
В качестве аварийного режима в этом описании зафиксирован профиль:
|
||||
|
||||
```text
|
||||
base240_1fps_q20_roi320_1fps_q30
|
||||
```
|
||||
|
||||
| Компонент | Параметры |
|
||||
|---|---|
|
||||
| BASE | 240x135, 1 кадр/с, JPEG Q20 |
|
||||
| ROI | 320x180, 1 кадр/с, JPEG Q30 |
|
||||
| BASE bitrate | 17.180610 кбит/с |
|
||||
| ROI bitrate | 33.306453 кбит/с |
|
||||
| Total payload bitrate | 50.487063 кбит/с |
|
||||
| Mean reconstructed full PSNR | 19.107784 дБ |
|
||||
| Mean reconstructed ROI PSNR | 17.873870 дБ |
|
||||
|
||||
## 7. Расчёт payload bitrate
|
||||
|
||||
Для одного JPEG-потока:
|
||||
|
||||
```text
|
||||
total_payload_bytes = сумма размеров выбранных JPEG-кадров
|
||||
|
||||
payload_bitrate_bps =
|
||||
total_payload_bytes * 8 / source_duration_seconds
|
||||
|
||||
payload_bitrate_kbps =
|
||||
payload_bitrate_bps / 1000
|
||||
```
|
||||
|
||||
Для Lab027:
|
||||
|
||||
```text
|
||||
total_payload_bytes =
|
||||
total_base_bytes + total_roi_bytes
|
||||
|
||||
base_bitrate_kbps =
|
||||
total_base_bytes * 8 / source_duration_seconds / 1000
|
||||
|
||||
roi_bitrate_kbps =
|
||||
total_roi_bytes * 8 / source_duration_seconds / 1000
|
||||
|
||||
total_payload_bitrate_kbps =
|
||||
base_bitrate_kbps + roi_bitrate_kbps
|
||||
```
|
||||
|
||||
Lab026B использует тот же принцип подсчёта полезной нагрузки, но размер
|
||||
кадра определяется выбранным режимом: JPEG, индексированный PNG либо
|
||||
упакованные и сжатые zlib индексы вместе с предусмотренными заголовком
|
||||
и палитрой.
|
||||
|
||||
## 8. Неучтённые накладные расходы радиоканала
|
||||
|
||||
В приведённые значения payload bitrate пока не включены:
|
||||
|
||||
- заголовки радиопакетов и служебные поля радиопротокола;
|
||||
- преамбула;
|
||||
- CRC;
|
||||
- FEC;
|
||||
- фрагментация кадров по радиопакетам;
|
||||
- межпакетные интервалы;
|
||||
- повторные передачи;
|
||||
- команды управления.
|
||||
|
||||
Значения также не включают оценку задержки полного тракта. PSNR и
|
||||
статические сравнительные изображения не заменяют ручную оценку
|
||||
пригодности изображения. Фиксированная ROI не гарантирует попадания
|
||||
препятствия в область интереса.
|
||||
|
||||
## 9. Итоговый рабочий видеорежим после Lab027C–Lab027E
|
||||
|
||||
Динамические сравнения Lab027C и Lab027D подтвердили, что раздельное
|
||||
асинхронное обновление BASE и ROI заметно оператору. Синхронное
|
||||
формирование обеих частей из одного исходного кадра устранило их
|
||||
визуальное рассогласование. Частота 2 кадр/с оказалась недостаточной,
|
||||
а 3 кадр/с по результатам ручной оценки признаны приемлемыми.
|
||||
|
||||
В качестве рабочего компромисса выбран следующий профиль:
|
||||
|
||||
| Компонент | Рабочие параметры |
|
||||
|---|---|
|
||||
| BASE | 240x135, grayscale, JPEG Q23 |
|
||||
| ROI | 320x180, grayscale, JPEG Q33 |
|
||||
| Обновление | синхронное и атомарное |
|
||||
| Частота составного изображения | 3 кадр/с |
|
||||
| JPEG payload | 156.342279 кбит/с |
|
||||
| Оценка с 10% overhead и FEC 2/3 | 257.964761 кбит/с |
|
||||
| Оценка с 10% overhead и FEC 1/2 | 343.953014 кбит/с |
|
||||
|
||||
Расчёт иллюстративных канальных скоростей:
|
||||
|
||||
```text
|
||||
channel_rate_fec_2_3_kbps =
|
||||
payload_kbps * 1.10 / (2 / 3)
|
||||
|
||||
channel_rate_fec_1_2_kbps =
|
||||
payload_kbps * 1.10 / (1 / 2)
|
||||
```
|
||||
|
||||
JPEG Q20 для BASE и Q30 для ROI признаны приемлемыми. Профиль Q23/Q33
|
||||
выбран как рабочий компромисс качества и потока. Визуальная разница
|
||||
между Q23/Q33 и Q25/Q35 практически отсутствует, поэтому увеличение
|
||||
потока для Q25/Q35 не дало заметного преимущества при ручной оценке.
|
||||
|
||||
При скорости ровера 25 км/ч линейная скорость равна примерно
|
||||
6.944 м/с. При трёх составных кадрах в секунду ровер проходит:
|
||||
|
||||
```text
|
||||
6.944 м/с / 3 кадр/с = 2.315 м/кадр
|
||||
```
|
||||
|
||||
То есть между соседними составными изображениями ровер перемещается
|
||||
примерно на 2.31 м. Эта оценка не включает задержку кодирования,
|
||||
радиоканала, ретранслятора, декодирования и реакции оператора.
|
||||
|
||||
Lab027E дополнительно использовала playback 0.5x для локальной
|
||||
визуальной имитации более медленного движения сцены. Замедление
|
||||
применялось только при создании операторского preview и не является
|
||||
частью передаваемого радиоканального формата. Реальная скорость
|
||||
автомобиля в исходном видео неизвестна.
|
||||
416
protocol/video_packet.py
Normal file
@@ -0,0 +1,416 @@
|
||||
"""
|
||||
Transport packets for synchronous BASE + ROI JPEG composite frames.
|
||||
|
||||
The wire header has a fixed 32-byte, padding-free network-byte-order layout::
|
||||
|
||||
!4sBBHIHHHHIII
|
||||
|
||||
Offset Size Field
|
||||
0 4 magic (b"SRV1")
|
||||
4 1 version
|
||||
5 1 object_type (1=BASE, 2=ROI)
|
||||
6 2 header_size
|
||||
8 4 composite_frame_id
|
||||
12 2 fragment_index
|
||||
14 2 fragment_count
|
||||
16 2 payload_length
|
||||
18 2 flags (reserved, must be zero)
|
||||
20 4 jpeg_size
|
||||
24 4 object_crc32
|
||||
28 4 packet_crc32
|
||||
|
||||
packet_crc32 is calculated over the complete header with packet_crc32 set to
|
||||
zero, followed by the packet payload. object_crc32 is calculated over the
|
||||
complete JPEG before fragmentation and checked after reassembly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
|
||||
MAGIC = b"SRV1"
|
||||
VERSION = 1
|
||||
HEADER_FORMAT = "!4sBBHIHHHHIII"
|
||||
HEADER_SIZE = struct.calcsize(HEADER_FORMAT)
|
||||
MAX_PAYLOAD_LENGTH = 0xFFFF
|
||||
MAX_FRAGMENT_COUNT = 0xFFFF
|
||||
MAX_JPEG_SIZE = 0xFFFFFFFF
|
||||
|
||||
|
||||
class ObjectType(IntEnum):
|
||||
"""JPEG object carried by a packet."""
|
||||
|
||||
BASE = 1
|
||||
ROI = 2
|
||||
|
||||
|
||||
class VideoPacketError(ValueError):
|
||||
"""Base class for video transport validation errors."""
|
||||
|
||||
|
||||
class PacketCRCError(VideoPacketError):
|
||||
"""The packet header or payload failed its CRC32 check."""
|
||||
|
||||
|
||||
class ObjectCRCError(VideoPacketError):
|
||||
"""A completely reassembled JPEG failed its CRC32 check."""
|
||||
|
||||
|
||||
class ObjectConsistencyError(VideoPacketError):
|
||||
"""Fragments for one object contain inconsistent metadata or data."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VideoPacket:
|
||||
"""Decoded and CRC-checked transport packet."""
|
||||
|
||||
composite_frame_id: int
|
||||
object_type: ObjectType
|
||||
fragment_index: int
|
||||
fragment_count: int
|
||||
jpeg_size: int
|
||||
object_crc32: int
|
||||
payload: bytes
|
||||
packet_crc32: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompositeFrame:
|
||||
"""Atomically reassembled BASE and ROI JPEGs for one source update."""
|
||||
|
||||
composite_frame_id: int
|
||||
base_jpeg: bytes
|
||||
roi_jpeg: bytes
|
||||
|
||||
|
||||
def crc32(data: bytes) -> int:
|
||||
"""Return an unsigned IEEE CRC32."""
|
||||
|
||||
return zlib.crc32(data) & 0xFFFFFFFF
|
||||
|
||||
|
||||
def _validate_packet_fields(packet: VideoPacket) -> None:
|
||||
if not 0 <= packet.composite_frame_id <= 0xFFFFFFFF:
|
||||
raise VideoPacketError("composite_frame_id is outside uint32")
|
||||
if packet.object_type not in (ObjectType.BASE, ObjectType.ROI):
|
||||
raise VideoPacketError("unsupported object_type")
|
||||
if not 1 <= packet.fragment_count <= MAX_FRAGMENT_COUNT:
|
||||
raise VideoPacketError("fragment_count is outside uint16")
|
||||
if not 0 <= packet.fragment_index < packet.fragment_count:
|
||||
raise VideoPacketError("fragment_index is outside fragment_count")
|
||||
if not 1 <= len(packet.payload) <= MAX_PAYLOAD_LENGTH:
|
||||
raise VideoPacketError("payload length is outside uint16")
|
||||
if not 1 <= packet.jpeg_size <= MAX_JPEG_SIZE:
|
||||
raise VideoPacketError("jpeg_size is outside uint32")
|
||||
if len(packet.payload) > packet.jpeg_size:
|
||||
raise VideoPacketError("payload is larger than the JPEG object")
|
||||
if not 0 <= packet.object_crc32 <= 0xFFFFFFFF:
|
||||
raise VideoPacketError("object_crc32 is outside uint32")
|
||||
|
||||
|
||||
def _pack_header(packet: VideoPacket, packet_crc32: int) -> bytes:
|
||||
return struct.pack(
|
||||
HEADER_FORMAT,
|
||||
MAGIC,
|
||||
VERSION,
|
||||
int(packet.object_type),
|
||||
HEADER_SIZE,
|
||||
packet.composite_frame_id,
|
||||
packet.fragment_index,
|
||||
packet.fragment_count,
|
||||
len(packet.payload),
|
||||
0,
|
||||
packet.jpeg_size,
|
||||
packet.object_crc32,
|
||||
packet_crc32,
|
||||
)
|
||||
|
||||
|
||||
def encode_packet(packet: VideoPacket) -> bytes:
|
||||
"""Serialize a packet and calculate its packet CRC32."""
|
||||
|
||||
if not isinstance(packet, VideoPacket):
|
||||
raise TypeError("packet must be VideoPacket")
|
||||
_validate_packet_fields(packet)
|
||||
header_with_zero_crc = _pack_header(packet, 0)
|
||||
packet_crc32 = crc32(header_with_zero_crc + packet.payload)
|
||||
return _pack_header(packet, packet_crc32) + packet.payload
|
||||
|
||||
|
||||
def decode_packet(wire_packet: bytes) -> VideoPacket:
|
||||
"""Deserialize a packet and validate its fixed header and packet CRC32."""
|
||||
|
||||
if not isinstance(wire_packet, (bytes, bytearray)):
|
||||
raise TypeError("wire_packet must be bytes or bytearray")
|
||||
wire_packet = bytes(wire_packet)
|
||||
if len(wire_packet) < HEADER_SIZE + 1:
|
||||
raise VideoPacketError("packet is shorter than header plus payload")
|
||||
|
||||
(
|
||||
magic,
|
||||
version,
|
||||
object_type_value,
|
||||
header_size,
|
||||
composite_frame_id,
|
||||
fragment_index,
|
||||
fragment_count,
|
||||
payload_length,
|
||||
flags,
|
||||
jpeg_size,
|
||||
object_crc32,
|
||||
received_packet_crc32,
|
||||
) = struct.unpack(HEADER_FORMAT, wire_packet[:HEADER_SIZE])
|
||||
|
||||
if magic != MAGIC:
|
||||
raise VideoPacketError("invalid packet magic")
|
||||
if version != VERSION:
|
||||
raise VideoPacketError("unsupported packet version")
|
||||
if header_size != HEADER_SIZE:
|
||||
raise VideoPacketError("invalid fixed header size")
|
||||
if flags != 0:
|
||||
raise VideoPacketError("reserved flags must be zero")
|
||||
try:
|
||||
object_type = ObjectType(object_type_value)
|
||||
except ValueError as error:
|
||||
raise VideoPacketError("unsupported object_type") from error
|
||||
|
||||
if len(wire_packet) != HEADER_SIZE + payload_length:
|
||||
raise VideoPacketError("packet length does not match payload_length")
|
||||
|
||||
packet = VideoPacket(
|
||||
composite_frame_id=composite_frame_id,
|
||||
object_type=object_type,
|
||||
fragment_index=fragment_index,
|
||||
fragment_count=fragment_count,
|
||||
jpeg_size=jpeg_size,
|
||||
object_crc32=object_crc32,
|
||||
payload=wire_packet[HEADER_SIZE:],
|
||||
packet_crc32=received_packet_crc32,
|
||||
)
|
||||
_validate_packet_fields(packet)
|
||||
calculated_packet_crc32 = crc32(
|
||||
_pack_header(packet, 0) + packet.payload
|
||||
)
|
||||
if calculated_packet_crc32 != received_packet_crc32:
|
||||
raise PacketCRCError(
|
||||
"packet CRC mismatch: "
|
||||
f"received 0x{received_packet_crc32:08X}, "
|
||||
f"calculated 0x{calculated_packet_crc32:08X}"
|
||||
)
|
||||
return packet
|
||||
|
||||
|
||||
def packetize_jpeg(
|
||||
jpeg: bytes,
|
||||
composite_frame_id: int,
|
||||
object_type: ObjectType,
|
||||
max_payload_length: int,
|
||||
) -> list[bytes]:
|
||||
"""Split one non-empty JPEG object into serialized packets."""
|
||||
|
||||
if not isinstance(jpeg, (bytes, bytearray)):
|
||||
raise TypeError("jpeg must be bytes or bytearray")
|
||||
jpeg = bytes(jpeg)
|
||||
if not jpeg:
|
||||
raise ValueError("JPEG object must not be empty")
|
||||
if len(jpeg) > MAX_JPEG_SIZE:
|
||||
raise ValueError("JPEG object is larger than uint32")
|
||||
if not 1 <= max_payload_length <= MAX_PAYLOAD_LENGTH:
|
||||
raise ValueError("max_payload_length is outside uint16")
|
||||
if not 0 <= composite_frame_id <= 0xFFFFFFFF:
|
||||
raise ValueError("composite_frame_id is outside uint32")
|
||||
try:
|
||||
object_type = ObjectType(object_type)
|
||||
except ValueError as error:
|
||||
raise ValueError("unsupported object_type") from error
|
||||
|
||||
fragment_count = (
|
||||
len(jpeg) + max_payload_length - 1
|
||||
) // max_payload_length
|
||||
if fragment_count > MAX_FRAGMENT_COUNT:
|
||||
raise ValueError("JPEG needs more than 65535 fragments")
|
||||
|
||||
object_crc32 = crc32(jpeg)
|
||||
packets = []
|
||||
for fragment_index in range(fragment_count):
|
||||
start = fragment_index * max_payload_length
|
||||
payload = jpeg[start:start + max_payload_length]
|
||||
packets.append(
|
||||
encode_packet(
|
||||
VideoPacket(
|
||||
composite_frame_id=composite_frame_id,
|
||||
object_type=object_type,
|
||||
fragment_index=fragment_index,
|
||||
fragment_count=fragment_count,
|
||||
jpeg_size=len(jpeg),
|
||||
object_crc32=object_crc32,
|
||||
payload=payload,
|
||||
)
|
||||
)
|
||||
)
|
||||
return packets
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ObjectAssembly:
|
||||
composite_frame_id: int
|
||||
object_type: ObjectType
|
||||
fragment_count: int
|
||||
jpeg_size: int
|
||||
object_crc32: int
|
||||
fragments: dict[int, bytes] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_packet(cls, packet: VideoPacket) -> "_ObjectAssembly":
|
||||
return cls(
|
||||
composite_frame_id=packet.composite_frame_id,
|
||||
object_type=packet.object_type,
|
||||
fragment_count=packet.fragment_count,
|
||||
jpeg_size=packet.jpeg_size,
|
||||
object_crc32=packet.object_crc32,
|
||||
)
|
||||
|
||||
def add(self, packet: VideoPacket) -> bool:
|
||||
metadata = (
|
||||
packet.composite_frame_id,
|
||||
packet.object_type,
|
||||
packet.fragment_count,
|
||||
packet.jpeg_size,
|
||||
packet.object_crc32,
|
||||
)
|
||||
expected = (
|
||||
self.composite_frame_id,
|
||||
self.object_type,
|
||||
self.fragment_count,
|
||||
self.jpeg_size,
|
||||
self.object_crc32,
|
||||
)
|
||||
if metadata != expected:
|
||||
raise ObjectConsistencyError(
|
||||
"fragment metadata conflicts with object assembly"
|
||||
)
|
||||
|
||||
existing = self.fragments.get(packet.fragment_index)
|
||||
if existing is not None:
|
||||
if existing != packet.payload:
|
||||
raise ObjectConsistencyError(
|
||||
"different payload for an existing fragment index"
|
||||
)
|
||||
return False
|
||||
self.fragments[packet.fragment_index] = packet.payload
|
||||
return True
|
||||
|
||||
def missing_fragments(self) -> tuple[int, ...]:
|
||||
return tuple(
|
||||
index
|
||||
for index in range(self.fragment_count)
|
||||
if index not in self.fragments
|
||||
)
|
||||
|
||||
def assemble_if_complete(self) -> bytes | None:
|
||||
if self.missing_fragments():
|
||||
return None
|
||||
jpeg = b"".join(
|
||||
self.fragments[index]
|
||||
for index in range(self.fragment_count)
|
||||
)
|
||||
if len(jpeg) != self.jpeg_size:
|
||||
raise ObjectConsistencyError(
|
||||
"reassembled JPEG length does not match jpeg_size"
|
||||
)
|
||||
calculated_object_crc32 = crc32(jpeg)
|
||||
if calculated_object_crc32 != self.object_crc32:
|
||||
raise ObjectCRCError(
|
||||
"object CRC mismatch: "
|
||||
f"received 0x{self.object_crc32:08X}, "
|
||||
f"calculated 0x{calculated_object_crc32:08X}"
|
||||
)
|
||||
return jpeg
|
||||
|
||||
|
||||
class CompositeReassembler:
|
||||
"""Reassemble interleaved BASE/ROI packets and publish atomic frames."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._objects: dict[
|
||||
tuple[int, ObjectType], _ObjectAssembly
|
||||
] = {}
|
||||
self._completed: dict[
|
||||
int, dict[ObjectType, bytes]
|
||||
] = {}
|
||||
self._finalized_frame_ids: set[int] = set()
|
||||
self.duplicate_packets = 0
|
||||
|
||||
def ingest(self, wire_packet: bytes) -> CompositeFrame | None:
|
||||
"""Accept one packet and return a frame only when BASE and ROI exist."""
|
||||
|
||||
packet = decode_packet(wire_packet)
|
||||
if packet.composite_frame_id in self._finalized_frame_ids:
|
||||
self.duplicate_packets += 1
|
||||
return None
|
||||
|
||||
key = (packet.composite_frame_id, packet.object_type)
|
||||
assembly = self._objects.get(key)
|
||||
if assembly is None:
|
||||
assembly = _ObjectAssembly.from_packet(packet)
|
||||
self._objects[key] = assembly
|
||||
|
||||
if not assembly.add(packet):
|
||||
self.duplicate_packets += 1
|
||||
return None
|
||||
|
||||
jpeg = assembly.assemble_if_complete()
|
||||
if jpeg is None:
|
||||
return None
|
||||
|
||||
frame_parts = self._completed.setdefault(
|
||||
packet.composite_frame_id, {}
|
||||
)
|
||||
frame_parts[packet.object_type] = jpeg
|
||||
if not all(
|
||||
object_type in frame_parts
|
||||
for object_type in (ObjectType.BASE, ObjectType.ROI)
|
||||
):
|
||||
return None
|
||||
|
||||
frame = CompositeFrame(
|
||||
composite_frame_id=packet.composite_frame_id,
|
||||
base_jpeg=frame_parts[ObjectType.BASE],
|
||||
roi_jpeg=frame_parts[ObjectType.ROI],
|
||||
)
|
||||
self._finalized_frame_ids.add(packet.composite_frame_id)
|
||||
self._completed.pop(packet.composite_frame_id, None)
|
||||
for object_type in (ObjectType.BASE, ObjectType.ROI):
|
||||
self._objects.pop(
|
||||
(packet.composite_frame_id, object_type), None
|
||||
)
|
||||
return frame
|
||||
|
||||
def missing_fragments(
|
||||
self,
|
||||
composite_frame_id: int,
|
||||
object_type: ObjectType,
|
||||
) -> tuple[int, ...] | None:
|
||||
"""Return missing indexes, or None if this object has not started."""
|
||||
|
||||
assembly = self._objects.get(
|
||||
(composite_frame_id, ObjectType(object_type))
|
||||
)
|
||||
if assembly is None:
|
||||
return None
|
||||
return assembly.missing_fragments()
|
||||
|
||||
def object_is_complete(
|
||||
self,
|
||||
composite_frame_id: int,
|
||||
object_type: ObjectType,
|
||||
) -> bool:
|
||||
"""Report whether one CRC-validated object awaits its counterpart."""
|
||||
|
||||
return ObjectType(object_type) in self._completed.get(
|
||||
composite_frame_id, {}
|
||||
)
|
||||
875
tests/lab025_pluto_wfm_demodulation.py
Normal file
@@ -0,0 +1,875 @@
|
||||
"""
|
||||
Lab025. Приём и программная WFM-демодуляция FM-радиостанции.
|
||||
|
||||
Схема подключения:
|
||||
антенна 40–860 МГц -> RX1
|
||||
|
||||
Используется только приёмный канал RX1. Передатчики TX1 и TX2
|
||||
не используются. Необработанные IQ-сэмплы на диск не сохраняются.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import adi
|
||||
import matplotlib
|
||||
import numpy as np
|
||||
from scipy import signal
|
||||
from scipy.io import wavfile
|
||||
|
||||
|
||||
# Для лабораторной графики сохраняется в файлы без блокирующих окон.
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Параметры приёмника Pluto+
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
PLUTO_URI = "ip:192.168.2.1"
|
||||
|
||||
STATION_FREQUENCY_HZ = 100_100_000
|
||||
LO_OFFSET_HZ = 250_000
|
||||
|
||||
RX_LO_FREQUENCY_HZ = (
|
||||
STATION_FREQUENCY_HZ + LO_OFFSET_HZ
|
||||
)
|
||||
|
||||
SAMPLE_RATE_HZ = 2_400_000
|
||||
RX_BANDWIDTH_HZ = 1_500_000
|
||||
|
||||
RX_BUFFER_SIZE = 65_536
|
||||
DISCARD_BUFFER_COUNT = 3
|
||||
CAPTURE_BUFFER_COUNT = 128
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Параметры обработки сигнала
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
CHANNEL_DECIMATION = 10
|
||||
CHANNEL_SAMPLE_RATE_HZ = 240_000
|
||||
|
||||
AUDIO_CUTOFF_HZ = 15_000
|
||||
AUDIO_SAMPLE_RATE_HZ = 48_000
|
||||
|
||||
DEEMPHASIS_TIME_CONSTANT_SECONDS = 50e-6
|
||||
|
||||
AUDIO_TRANSIENT_DURATION_SECONDS = 0.05
|
||||
AUDIO_REFERENCE_PERCENTILE = 99.5
|
||||
AUDIO_TARGET_LEVEL = 0.85
|
||||
MINIMUM_AUDIO_REFERENCE_PEAK = 1e-12
|
||||
|
||||
RF_WELCH_MAXIMUM_SAMPLE_COUNT = 1_048_576
|
||||
RF_WELCH_SEGMENT_LENGTH = 8_192
|
||||
RF_WELCH_OVERLAP_LENGTH = 4_096
|
||||
|
||||
AUDIO_WELCH_SEGMENT_LENGTH = 8_192
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Выходные файлы
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
OUTPUT_DIRECTORY = Path("data/processed/lab025")
|
||||
|
||||
WAV_FILE_PATH = (
|
||||
OUTPUT_DIRECTORY / "lab025_wfm_audio_100_1mhz.wav"
|
||||
)
|
||||
RF_SPECTRUM_FILE_PATH = (
|
||||
OUTPUT_DIRECTORY / "lab025_rf_spectrum.png"
|
||||
)
|
||||
AUDIO_WAVEFORM_FILE_PATH = (
|
||||
OUTPUT_DIRECTORY / "lab025_audio_waveform.png"
|
||||
)
|
||||
AUDIO_SPECTRUM_FILE_PATH = (
|
||||
OUTPUT_DIRECTORY / "lab025_audio_spectrum.png"
|
||||
)
|
||||
REPORT_FILE_PATH = OUTPUT_DIRECTORY / "lab025_report.txt"
|
||||
|
||||
|
||||
def configure_receiver() -> adi.Pluto:
|
||||
"""
|
||||
Подключается к Pluto+ и настраивает только приёмный канал RX1.
|
||||
|
||||
Частота гетеродина смещена на 250 кГц выше частоты станции.
|
||||
Благодаря этому полезный сигнал не совпадает с аппаратным DC-пиком
|
||||
в центре комплексной полосы приёмника.
|
||||
"""
|
||||
|
||||
print("Подключение к Pluto+...")
|
||||
|
||||
sdr = adi.Pluto(uri=PLUTO_URI)
|
||||
|
||||
# Используем только первый приёмный канал RX1.
|
||||
sdr.rx_enabled_channels = [0]
|
||||
|
||||
sdr.sample_rate = SAMPLE_RATE_HZ
|
||||
sdr.rx_lo = RX_LO_FREQUENCY_HZ
|
||||
sdr.rx_rf_bandwidth = RX_BANDWIDTH_HZ
|
||||
|
||||
# Медленная АРУ подходит для приёма вещательной FM-станции.
|
||||
sdr.gain_control_mode_chan0 = "slow_attack"
|
||||
sdr.rx_buffer_size = RX_BUFFER_SIZE
|
||||
|
||||
return sdr
|
||||
|
||||
|
||||
def receive_samples(sdr: adi.Pluto) -> np.ndarray:
|
||||
"""
|
||||
Получает последовательность комплексных IQ-буферов.
|
||||
|
||||
Первые буферы отбрасываются, поскольку после настройки приёмника
|
||||
в них могут присутствовать переходные процессы АРУ и фильтров.
|
||||
Рабочие буферы объединяются только в оперативной памяти.
|
||||
"""
|
||||
|
||||
print()
|
||||
print(
|
||||
"Отбрасывание переходных буферов: "
|
||||
f"{DISCARD_BUFFER_COUNT}..."
|
||||
)
|
||||
|
||||
for _ in range(DISCARD_BUFFER_COUNT):
|
||||
_ = sdr.rx()
|
||||
|
||||
print("Получение рабочих IQ-буферов...")
|
||||
|
||||
received_buffers: list[np.ndarray] = []
|
||||
|
||||
for buffer_number in range(1, CAPTURE_BUFFER_COUNT + 1):
|
||||
received_buffer = np.asarray(
|
||||
sdr.rx(),
|
||||
dtype=np.complex64,
|
||||
)
|
||||
|
||||
if received_buffer.size == 0:
|
||||
raise RuntimeError(
|
||||
"Pluto+ вернул пустой рабочий IQ-буфер."
|
||||
)
|
||||
|
||||
received_buffers.append(received_buffer)
|
||||
|
||||
if (
|
||||
buffer_number % 16 == 0
|
||||
or buffer_number == CAPTURE_BUFFER_COUNT
|
||||
):
|
||||
print(
|
||||
f" Принято {buffer_number:3d}/"
|
||||
f"{CAPTURE_BUFFER_COUNT} буферов"
|
||||
)
|
||||
|
||||
if not received_buffers:
|
||||
raise RuntimeError("Pluto+ не вернул IQ-сэмплы.")
|
||||
|
||||
combined_samples = np.concatenate(received_buffers)
|
||||
|
||||
if combined_samples.size == 0:
|
||||
raise RuntimeError("После объединения получен пустой IQ-массив.")
|
||||
|
||||
if not np.all(np.isfinite(combined_samples)):
|
||||
raise RuntimeError("В IQ-сэмплах обнаружены NaN или Inf.")
|
||||
|
||||
return np.asarray(combined_samples, dtype=np.complex64)
|
||||
|
||||
|
||||
def shift_station_to_baseband(
|
||||
samples: np.ndarray,
|
||||
sample_rate_hz: float,
|
||||
frequency_shift_hz: float,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Переносит выбранную станцию в центр цифровой полосы.
|
||||
|
||||
При положительном смещении комплексный генератор переносит сигнал,
|
||||
расположенный на отрицательной относительной частоте, к 0 Гц.
|
||||
"""
|
||||
|
||||
if samples.size == 0:
|
||||
raise RuntimeError(
|
||||
"Невозможно выполнить цифровой перенос пустого IQ-массива."
|
||||
)
|
||||
|
||||
sample_indices = np.arange(
|
||||
len(samples),
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
digital_oscillator = np.exp(
|
||||
1j
|
||||
* 2.0
|
||||
* np.pi
|
||||
* frequency_shift_hz
|
||||
* sample_indices
|
||||
/ sample_rate_hz
|
||||
).astype(np.complex64)
|
||||
|
||||
centered_samples = samples * digital_oscillator
|
||||
|
||||
if not np.all(np.isfinite(centered_samples)):
|
||||
raise RuntimeError(
|
||||
"После цифрового переноса обнаружены NaN или Inf."
|
||||
)
|
||||
|
||||
return np.asarray(centered_samples, dtype=np.complex64)
|
||||
|
||||
|
||||
def extract_wfm_channel(
|
||||
centered_samples: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Фильтрует WFM-канал и понижает частоту до 240 кГц.
|
||||
|
||||
Полигармоническая передискретизация одновременно выполняет
|
||||
низкочастотную фильтрацию и децимацию в десять раз.
|
||||
"""
|
||||
|
||||
calculated_sample_rate_hz = (
|
||||
SAMPLE_RATE_HZ / CHANNEL_DECIMATION
|
||||
)
|
||||
|
||||
if not np.isclose(
|
||||
calculated_sample_rate_hz,
|
||||
CHANNEL_SAMPLE_RATE_HZ,
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Частота канального сигнала после децимации "
|
||||
"не равна 240 кГц."
|
||||
)
|
||||
|
||||
channel_samples = signal.resample_poly(
|
||||
centered_samples,
|
||||
up=1,
|
||||
down=CHANNEL_DECIMATION,
|
||||
window=("kaiser", 8.0),
|
||||
)
|
||||
|
||||
if channel_samples.size == 0:
|
||||
raise RuntimeError("После выделения WFM-канала массив пуст.")
|
||||
|
||||
if not np.all(np.isfinite(channel_samples)):
|
||||
raise RuntimeError(
|
||||
"После выделения WFM-канала обнаружены NaN или Inf."
|
||||
)
|
||||
|
||||
return np.asarray(channel_samples, dtype=np.complex64)
|
||||
|
||||
|
||||
def demodulate_fm(
|
||||
channel_samples: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Выполняет частотную демодуляцию по фазовой разности отсчётов.
|
||||
|
||||
Результат представляет монофонический композитный FM-сигнал
|
||||
до звуковой фильтрации и коррекции предыскажений.
|
||||
"""
|
||||
|
||||
if channel_samples.size < 2:
|
||||
raise RuntimeError(
|
||||
"Недостаточно канальных отсчётов для FM-демодуляции."
|
||||
)
|
||||
|
||||
phase_difference = np.angle(
|
||||
channel_samples[1:]
|
||||
* np.conj(channel_samples[:-1])
|
||||
).astype(np.float64)
|
||||
|
||||
demodulated = phase_difference - np.mean(phase_difference)
|
||||
|
||||
if demodulated.size == 0:
|
||||
raise RuntimeError("FM-дискриминатор вернул пустой массив.")
|
||||
|
||||
if not np.all(np.isfinite(demodulated)):
|
||||
raise RuntimeError(
|
||||
"После FM-демодуляции обнаружены NaN или Inf."
|
||||
)
|
||||
|
||||
return demodulated
|
||||
|
||||
|
||||
def lowpass_audio(
|
||||
demodulated_samples: np.ndarray,
|
||||
sample_rate_hz: float,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Выделяет монофонический звук L+R в полосе до 15 кГц.
|
||||
|
||||
Фильтр подавляет стереопилот 19 кГц, стереоразностную часть,
|
||||
RDS и внеполосный высокочастотный шум.
|
||||
"""
|
||||
|
||||
audio_sos = signal.butter(
|
||||
6,
|
||||
AUDIO_CUTOFF_HZ,
|
||||
btype="lowpass",
|
||||
fs=sample_rate_hz,
|
||||
output="sos",
|
||||
)
|
||||
|
||||
filtered_audio = signal.sosfiltfilt(
|
||||
audio_sos,
|
||||
demodulated_samples,
|
||||
)
|
||||
|
||||
if filtered_audio.size == 0:
|
||||
raise RuntimeError("Звуковой фильтр вернул пустой массив.")
|
||||
|
||||
if not np.all(np.isfinite(filtered_audio)):
|
||||
raise RuntimeError(
|
||||
"После звукового фильтра обнаружены NaN или Inf."
|
||||
)
|
||||
|
||||
return np.asarray(filtered_audio, dtype=np.float64)
|
||||
|
||||
|
||||
def apply_deemphasis(
|
||||
audio_samples: np.ndarray,
|
||||
sample_rate_hz: float,
|
||||
time_constant_seconds: float,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Выполняет европейскую FM-коррекцию предыскажений 50 мкс.
|
||||
|
||||
Используется устойчивый однополюсный рекурсивный фильтр,
|
||||
реализованный функцией scipy.signal.lfilter.
|
||||
"""
|
||||
|
||||
alpha = np.exp(
|
||||
-1.0
|
||||
/ (
|
||||
sample_rate_hz
|
||||
* time_constant_seconds
|
||||
)
|
||||
)
|
||||
|
||||
deemphasized = signal.lfilter(
|
||||
[1.0 - alpha],
|
||||
[1.0, -alpha],
|
||||
audio_samples,
|
||||
)
|
||||
|
||||
deemphasized = deemphasized - np.mean(deemphasized)
|
||||
|
||||
if deemphasized.size == 0:
|
||||
raise RuntimeError("De-emphasis вернул пустой массив.")
|
||||
|
||||
if not np.all(np.isfinite(deemphasized)):
|
||||
raise RuntimeError(
|
||||
"После de-emphasis обнаружены NaN или Inf."
|
||||
)
|
||||
|
||||
return np.asarray(deemphasized, dtype=np.float64)
|
||||
|
||||
|
||||
def resample_audio_to_output_rate(
|
||||
audio_samples: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Преобразует звуковой сигнал с 240 кГц в выходные 48 кГц.
|
||||
"""
|
||||
|
||||
output_decimation = 5
|
||||
calculated_output_rate_hz = (
|
||||
CHANNEL_SAMPLE_RATE_HZ / output_decimation
|
||||
)
|
||||
|
||||
if not np.isclose(
|
||||
calculated_output_rate_hz,
|
||||
AUDIO_SAMPLE_RATE_HZ,
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Рассчитанная частота WAV не равна 48 кГц."
|
||||
)
|
||||
|
||||
output_audio = signal.resample_poly(
|
||||
audio_samples,
|
||||
up=1,
|
||||
down=output_decimation,
|
||||
)
|
||||
|
||||
if output_audio.size == 0:
|
||||
raise RuntimeError(
|
||||
"После преобразования в 48 кГц получен пустой массив."
|
||||
)
|
||||
|
||||
if not np.all(np.isfinite(output_audio)):
|
||||
raise RuntimeError(
|
||||
"После преобразования звука обнаружены NaN или Inf."
|
||||
)
|
||||
|
||||
return np.asarray(output_audio, dtype=np.float64)
|
||||
|
||||
|
||||
def convert_audio_to_pcm16(
|
||||
audio_samples: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Удаляет переходный участок, нормализует звук и создаёт PCM16.
|
||||
|
||||
Опорный уровень определяется по 99,5-му процентилю модуля,
|
||||
поэтому единичный выброс не делает весь WAV слишком тихим.
|
||||
"""
|
||||
|
||||
if audio_samples.size == 0:
|
||||
raise RuntimeError("Невозможно нормализовать пустой звук.")
|
||||
|
||||
centered_audio = audio_samples - np.mean(audio_samples)
|
||||
|
||||
transient_sample_count = int(
|
||||
round(
|
||||
AUDIO_TRANSIENT_DURATION_SECONDS
|
||||
* AUDIO_SAMPLE_RATE_HZ
|
||||
)
|
||||
)
|
||||
|
||||
if centered_audio.size <= transient_sample_count:
|
||||
raise RuntimeError(
|
||||
"Звуковой массив короче переходного участка 0,05 с."
|
||||
)
|
||||
|
||||
centered_audio = centered_audio[transient_sample_count:]
|
||||
|
||||
reference_peak = float(
|
||||
np.percentile(
|
||||
np.abs(centered_audio),
|
||||
AUDIO_REFERENCE_PERCENTILE,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
not np.isfinite(reference_peak)
|
||||
or reference_peak <= MINIMUM_AUDIO_REFERENCE_PEAK
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Сигнал слишком мал для безопасной нормализации PCM16."
|
||||
)
|
||||
|
||||
normalized_audio = (
|
||||
centered_audio
|
||||
/ reference_peak
|
||||
* AUDIO_TARGET_LEVEL
|
||||
)
|
||||
|
||||
normalized_audio = np.clip(
|
||||
normalized_audio,
|
||||
-1.0,
|
||||
1.0,
|
||||
)
|
||||
|
||||
pcm16_audio = np.round(
|
||||
normalized_audio * np.iinfo(np.int16).max
|
||||
).astype(np.int16)
|
||||
|
||||
if pcm16_audio.size == 0:
|
||||
raise RuntimeError("После преобразования PCM16 массив пуст.")
|
||||
|
||||
return pcm16_audio
|
||||
|
||||
|
||||
def calculate_rf_spectrum(
|
||||
samples: np.ndarray,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""
|
||||
Рассчитывает ограниченный спектр исходного IQ методом Уэлча.
|
||||
|
||||
Для графика используется не более 1 048 576 отсчётов, поэтому
|
||||
полная восьмимиллионная запись не передаётся в одну большую FFT.
|
||||
"""
|
||||
|
||||
analysis_sample_count = min(
|
||||
len(samples),
|
||||
RF_WELCH_MAXIMUM_SAMPLE_COUNT,
|
||||
)
|
||||
|
||||
analysis_samples = samples[:analysis_sample_count]
|
||||
|
||||
relative_frequencies_hz, power_density = signal.welch(
|
||||
analysis_samples,
|
||||
fs=SAMPLE_RATE_HZ,
|
||||
window="hann",
|
||||
nperseg=RF_WELCH_SEGMENT_LENGTH,
|
||||
noverlap=RF_WELCH_OVERLAP_LENGTH,
|
||||
detrend=False,
|
||||
return_onesided=False,
|
||||
scaling="density",
|
||||
)
|
||||
|
||||
relative_frequencies_hz = np.fft.fftshift(
|
||||
relative_frequencies_hz
|
||||
)
|
||||
power_density = np.fft.fftshift(power_density)
|
||||
|
||||
minimum_positive_value = np.finfo(np.float64).tiny
|
||||
power_db = 10.0 * np.log10(
|
||||
power_density + minimum_positive_value
|
||||
)
|
||||
power_db -= np.max(power_db)
|
||||
|
||||
return relative_frequencies_hz, power_db
|
||||
|
||||
|
||||
def create_rf_spectrum_graph(samples: np.ndarray) -> None:
|
||||
"""
|
||||
Сохраняет спектр до цифрового переноса станции в центр.
|
||||
"""
|
||||
|
||||
frequencies_hz, power_db = calculate_rf_spectrum(samples)
|
||||
|
||||
figure, axes = plt.subplots(figsize=(13, 7))
|
||||
|
||||
axes.plot(
|
||||
frequencies_hz / 1e3,
|
||||
power_db,
|
||||
linewidth=0.8,
|
||||
)
|
||||
|
||||
expected_station_offset_hz = -LO_OFFSET_HZ
|
||||
|
||||
axes.axvline(
|
||||
expected_station_offset_hz / 1e3,
|
||||
color="tab:red",
|
||||
linestyle="--",
|
||||
linewidth=1.4,
|
||||
label=(
|
||||
"Ожидаемая станция 100,100 МГц "
|
||||
"(-250 кГц)"
|
||||
),
|
||||
)
|
||||
|
||||
axes.set_title(
|
||||
"Lab025. Спектр принятого IQ до цифрового переноса\n"
|
||||
"RX LO = 100,350 МГц; станция = 100,100 МГц"
|
||||
)
|
||||
axes.set_xlabel("Относительная частота относительно RX LO, кГц")
|
||||
axes.set_ylabel("Относительная мощность, дБ")
|
||||
axes.set_ylim(-90, 5)
|
||||
axes.grid(True)
|
||||
axes.legend()
|
||||
|
||||
figure.tight_layout()
|
||||
figure.savefig(RF_SPECTRUM_FILE_PATH, dpi=160)
|
||||
plt.close(figure)
|
||||
|
||||
|
||||
def create_audio_waveform_graph(pcm16_audio: np.ndarray) -> None:
|
||||
"""
|
||||
Сохраняет первые 0,1 секунды нормированного звука.
|
||||
"""
|
||||
|
||||
displayed_sample_count = min(
|
||||
len(pcm16_audio),
|
||||
int(round(0.1 * AUDIO_SAMPLE_RATE_HZ)),
|
||||
)
|
||||
|
||||
displayed_audio = (
|
||||
pcm16_audio[:displayed_sample_count].astype(np.float64)
|
||||
/ np.iinfo(np.int16).max
|
||||
)
|
||||
time_seconds = (
|
||||
np.arange(displayed_sample_count, dtype=np.float64)
|
||||
/ AUDIO_SAMPLE_RATE_HZ
|
||||
)
|
||||
|
||||
figure, axes = plt.subplots(figsize=(12, 5))
|
||||
axes.plot(time_seconds, displayed_audio, linewidth=0.8)
|
||||
axes.set_title("Lab025. Первые 0,1 секунды демодулированного звука")
|
||||
axes.set_xlabel("Время, с")
|
||||
axes.set_ylabel("Нормированная амплитуда")
|
||||
axes.set_ylim(-1.05, 1.05)
|
||||
axes.grid(True)
|
||||
|
||||
figure.tight_layout()
|
||||
figure.savefig(AUDIO_WAVEFORM_FILE_PATH, dpi=160)
|
||||
plt.close(figure)
|
||||
|
||||
|
||||
def create_audio_spectrum_graph(pcm16_audio: np.ndarray) -> None:
|
||||
"""
|
||||
Рассчитывает методом Уэлча и сохраняет односторонний спектр WAV.
|
||||
"""
|
||||
|
||||
normalized_audio = (
|
||||
pcm16_audio.astype(np.float64)
|
||||
/ np.iinfo(np.int16).max
|
||||
)
|
||||
|
||||
segment_length = min(
|
||||
AUDIO_WELCH_SEGMENT_LENGTH,
|
||||
len(normalized_audio),
|
||||
)
|
||||
|
||||
frequencies_hz, power_density = signal.welch(
|
||||
normalized_audio,
|
||||
fs=AUDIO_SAMPLE_RATE_HZ,
|
||||
window="hann",
|
||||
nperseg=segment_length,
|
||||
noverlap=segment_length // 2,
|
||||
detrend="constant",
|
||||
return_onesided=True,
|
||||
scaling="density",
|
||||
)
|
||||
|
||||
minimum_positive_value = np.finfo(np.float64).tiny
|
||||
power_db = 10.0 * np.log10(
|
||||
power_density + minimum_positive_value
|
||||
)
|
||||
power_db -= np.max(power_db)
|
||||
|
||||
figure, axes = plt.subplots(figsize=(12, 6))
|
||||
axes.plot(frequencies_hz / 1e3, power_db, linewidth=0.9)
|
||||
axes.set_xlim(0, 20)
|
||||
axes.set_ylim(-100, 5)
|
||||
axes.set_title("Lab025. Спектр демодулированного монофонического звука")
|
||||
axes.set_xlabel("Частота, кГц")
|
||||
axes.set_ylabel("Относительная спектральная плотность, дБ")
|
||||
axes.grid(True)
|
||||
|
||||
figure.tight_layout()
|
||||
figure.savefig(AUDIO_SPECTRUM_FILE_PATH, dpi=160)
|
||||
plt.close(figure)
|
||||
|
||||
|
||||
def calculate_audio_statistics(
|
||||
pcm16_audio: np.ndarray,
|
||||
) -> tuple[float, float, float]:
|
||||
"""
|
||||
Возвращает нормированные RMS, пик и процент предельных отсчётов.
|
||||
"""
|
||||
|
||||
normalized_audio = (
|
||||
pcm16_audio.astype(np.float64)
|
||||
/ np.iinfo(np.int16).max
|
||||
)
|
||||
|
||||
rms_value = float(
|
||||
np.sqrt(np.mean(normalized_audio ** 2))
|
||||
)
|
||||
peak_value = float(np.max(np.abs(normalized_audio)))
|
||||
|
||||
clipped_sample_count = int(
|
||||
np.count_nonzero(
|
||||
(pcm16_audio == np.iinfo(np.int16).min)
|
||||
| (pcm16_audio == np.iinfo(np.int16).max)
|
||||
)
|
||||
)
|
||||
clipping_percentage = (
|
||||
100.0
|
||||
* clipped_sample_count
|
||||
/ len(pcm16_audio)
|
||||
)
|
||||
|
||||
return rms_value, peak_value, clipping_percentage
|
||||
|
||||
|
||||
def save_report(
|
||||
iq_sample_count: int,
|
||||
iq_duration_seconds: float,
|
||||
pcm16_audio: np.ndarray,
|
||||
audio_rms: float,
|
||||
audio_peak: float,
|
||||
clipping_percentage: float,
|
||||
) -> None:
|
||||
"""
|
||||
Создаёт текстовый отчёт с параметрами приёма и WAV-файла.
|
||||
"""
|
||||
|
||||
wav_duration_seconds = (
|
||||
len(pcm16_audio) / AUDIO_SAMPLE_RATE_HZ
|
||||
)
|
||||
|
||||
report_lines = [
|
||||
"Lab025. Приём и программная WFM-демодуляция",
|
||||
"",
|
||||
f"URI Pluto+: {PLUTO_URI}",
|
||||
f"Частота станции: {STATION_FREQUENCY_HZ} Гц",
|
||||
f"RX LO: {RX_LO_FREQUENCY_HZ} Гц",
|
||||
f"Цифровое смещение: +{LO_OFFSET_HZ} Гц",
|
||||
f"Частота дискретизации RX: {SAMPLE_RATE_HZ} Гц",
|
||||
f"Полоса RX: {RX_BANDWIDTH_HZ} Гц",
|
||||
f"Количество рабочих буферов: {CAPTURE_BUFFER_COUNT}",
|
||||
f"Количество IQ-сэмплов: {iq_sample_count}",
|
||||
f"Длительность IQ-записи: {iq_duration_seconds:.6f} с",
|
||||
(
|
||||
"Частота канального сигнала после децимации: "
|
||||
f"{CHANNEL_SAMPLE_RATE_HZ} Гц"
|
||||
),
|
||||
f"Длительность итогового WAV: {wav_duration_seconds:.6f} с",
|
||||
f"Частота WAV: {AUDIO_SAMPLE_RATE_HZ} Гц",
|
||||
"Тип PCM: signed PCM16, mono",
|
||||
f"RMS итогового аудио: {audio_rms:.6f}",
|
||||
f"Пиковая амплитуда: {audio_peak:.6f}",
|
||||
f"Отсчёты на границе PCM16: {clipping_percentage:.6f} %",
|
||||
"",
|
||||
"Выходные файлы:",
|
||||
f"WAV: {WAV_FILE_PATH}",
|
||||
f"Радиоспектр: {RF_SPECTRUM_FILE_PATH}",
|
||||
f"Звуковая волна: {AUDIO_WAVEFORM_FILE_PATH}",
|
||||
f"Спектр звука: {AUDIO_SPECTRUM_FILE_PATH}",
|
||||
f"Отчёт: {REPORT_FILE_PATH}",
|
||||
"",
|
||||
"Использован только приёмный канал RX1.",
|
||||
"Передатчики TX1 и TX2 не использовались.",
|
||||
"Необработанные IQ-сэмплы на диск не сохранялись.",
|
||||
]
|
||||
|
||||
REPORT_FILE_PATH.write_text(
|
||||
"\n".join(report_lines),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
Выполняет полный цикл приёма, WFM-демодуляции и сохранения WAV.
|
||||
"""
|
||||
|
||||
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sdr = None
|
||||
|
||||
try:
|
||||
sdr = configure_receiver()
|
||||
|
||||
print()
|
||||
print("Параметры RX:")
|
||||
print(f" URI: {PLUTO_URI}")
|
||||
print(
|
||||
f" Станция: "
|
||||
f"{STATION_FREQUENCY_HZ / 1e6:.3f} МГц"
|
||||
)
|
||||
print(
|
||||
f" RX LO: "
|
||||
f"{sdr.rx_lo / 1e6:.3f} МГц"
|
||||
)
|
||||
print(
|
||||
f" Частота дискретизации: "
|
||||
f"{sdr.sample_rate / 1e6:.3f} Мвыб/с"
|
||||
)
|
||||
print(
|
||||
f" Полоса RX: "
|
||||
f"{sdr.rx_rf_bandwidth / 1e6:.3f} МГц"
|
||||
)
|
||||
print(
|
||||
f" Режим усиления: "
|
||||
f"{sdr.gain_control_mode_chan0}"
|
||||
)
|
||||
print(f" Размер буфера: {sdr.rx_buffer_size}")
|
||||
print(" Активный канал: RX1")
|
||||
|
||||
samples = receive_samples(sdr)
|
||||
|
||||
iq_sample_count = len(samples)
|
||||
iq_duration_seconds = iq_sample_count / SAMPLE_RATE_HZ
|
||||
|
||||
print()
|
||||
print(f"Количество IQ-сэмплов: {iq_sample_count}")
|
||||
print(f"Длительность записи: {iq_duration_seconds:.3f} с")
|
||||
|
||||
# Удаляем остаточную комплексную постоянную составляющую.
|
||||
samples = samples - np.mean(samples)
|
||||
|
||||
print()
|
||||
print("Цифровой перенос станции в центр полосы...")
|
||||
centered_samples = shift_station_to_baseband(
|
||||
samples=samples,
|
||||
sample_rate_hz=SAMPLE_RATE_HZ,
|
||||
frequency_shift_hz=LO_OFFSET_HZ,
|
||||
)
|
||||
|
||||
print("Фильтрация WFM-канала и децимация до 240 кГц...")
|
||||
channel_samples = extract_wfm_channel(centered_samples)
|
||||
|
||||
print("FM-демодуляция...")
|
||||
demodulated_samples = demodulate_fm(channel_samples)
|
||||
|
||||
print("Звуковой low-pass фильтр 0–15 кГц...")
|
||||
filtered_audio = lowpass_audio(
|
||||
demodulated_samples=demodulated_samples,
|
||||
sample_rate_hz=CHANNEL_SAMPLE_RATE_HZ,
|
||||
)
|
||||
|
||||
print("De-emphasis 50 мкс...")
|
||||
deemphasized_audio = apply_deemphasis(
|
||||
audio_samples=filtered_audio,
|
||||
sample_rate_hz=CHANNEL_SAMPLE_RATE_HZ,
|
||||
time_constant_seconds=(
|
||||
DEEMPHASIS_TIME_CONSTANT_SECONDS
|
||||
),
|
||||
)
|
||||
|
||||
print("Преобразование звука в 48 кГц...")
|
||||
output_audio = resample_audio_to_output_rate(
|
||||
deemphasized_audio
|
||||
)
|
||||
pcm16_audio = convert_audio_to_pcm16(output_audio)
|
||||
|
||||
audio_rms, audio_peak, clipping_percentage = (
|
||||
calculate_audio_statistics(pcm16_audio)
|
||||
)
|
||||
|
||||
print("Сохранение WAV...")
|
||||
wavfile.write(
|
||||
WAV_FILE_PATH,
|
||||
AUDIO_SAMPLE_RATE_HZ,
|
||||
pcm16_audio,
|
||||
)
|
||||
|
||||
if not WAV_FILE_PATH.is_file():
|
||||
raise RuntimeError("Выходной WAV-файл не создан.")
|
||||
|
||||
print("Сохранение графиков...")
|
||||
create_rf_spectrum_graph(samples)
|
||||
create_audio_waveform_graph(pcm16_audio)
|
||||
create_audio_spectrum_graph(pcm16_audio)
|
||||
|
||||
print("Создание текстового отчёта...")
|
||||
save_report(
|
||||
iq_sample_count=iq_sample_count,
|
||||
iq_duration_seconds=iq_duration_seconds,
|
||||
pcm16_audio=pcm16_audio,
|
||||
audio_rms=audio_rms,
|
||||
audio_peak=audio_peak,
|
||||
clipping_percentage=clipping_percentage,
|
||||
)
|
||||
|
||||
print()
|
||||
print("Созданы файлы:")
|
||||
print(f" WAV: {WAV_FILE_PATH}")
|
||||
print(f" Радиоспектр: {RF_SPECTRUM_FILE_PATH}")
|
||||
print(f" Звуковая волна: {AUDIO_WAVEFORM_FILE_PATH}")
|
||||
print(f" Спектр звука: {AUDIO_SPECTRUM_FILE_PATH}")
|
||||
print(f" Отчёт: {REPORT_FILE_PATH}")
|
||||
print()
|
||||
print(f"RMS аудио: {audio_rms:.6f}")
|
||||
print(f"Пиковая амплитуда: {audio_peak:.6f}")
|
||||
print(
|
||||
f"Отсчёты на границе PCM: "
|
||||
f"{clipping_percentage:.6f} %"
|
||||
)
|
||||
print()
|
||||
print("Lab025 выполнена успешно.")
|
||||
print()
|
||||
print("Для прослушивания откройте:")
|
||||
print(WAV_FILE_PATH)
|
||||
|
||||
finally:
|
||||
if sdr is not None:
|
||||
destroy_buffer = getattr(
|
||||
sdr,
|
||||
"rx_destroy_buffer",
|
||||
None,
|
||||
)
|
||||
|
||||
if callable(destroy_buffer):
|
||||
try:
|
||||
destroy_buffer()
|
||||
except Exception as cleanup_error:
|
||||
print(
|
||||
"Предупреждение: не удалось освободить "
|
||||
f"RX-буфер: {cleanup_error}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1358
tests/lab026_video_bitrate_benchmark.py
Normal file
1861
tests/lab026b_four_bit_video_benchmark.py
Normal file
1710
tests/lab027_roi_multiresolution_benchmark.py
Normal file
925
tests/lab027_roi_temporal_preview.py
Normal file
@@ -0,0 +1,925 @@
|
||||
"""
|
||||
Lab027. Динамическое превью временного обновления BASE и ROI.
|
||||
|
||||
Скрипт не повторяет расчёт benchmark Lab027 и не изменяет его
|
||||
результаты. Он создаёт одно игнорируемое Git preview-видео с четырьмя
|
||||
фиксированными профилями. Для каждого профиля независимо удерживаются
|
||||
последние декодированные JPEG-состояния BASE и ROI.
|
||||
|
||||
Отдельные JPEG-файлы не сохраняются: кодирование и декодирование
|
||||
выполняются только в памяти средствами OpenCV.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
SOURCE_VIDEO_PATH = Path("data/raw/lab026_rover_source.mp4")
|
||||
|
||||
PREVIEW_DIRECTORY = Path("data/raw/lab027_previews")
|
||||
MP4_PREVIEW_PATH = (
|
||||
PREVIEW_DIRECTORY / "lab027_roi_temporal_preview.mp4"
|
||||
)
|
||||
AVI_PREVIEW_PATH = (
|
||||
PREVIEW_DIRECTORY / "lab027_roi_temporal_preview.avi"
|
||||
)
|
||||
|
||||
OUTPUT_FPS = 30.0
|
||||
PANEL_WIDTH = 640
|
||||
PANEL_HEIGHT = 360
|
||||
OUTPUT_WIDTH = PANEL_WIDTH * 2
|
||||
OUTPUT_HEIGHT = PANEL_HEIGHT * 2
|
||||
|
||||
ROI_X_MIN = 0.20
|
||||
ROI_X_MAX = 0.80
|
||||
ROI_Y_MIN = 0.42
|
||||
ROI_Y_MAX = 1.00
|
||||
|
||||
FRAME_TIME_EPSILON_SECONDS = 1e-9
|
||||
NEW_UPDATE_LABEL_SECONDS = 0.15
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreviewProfile:
|
||||
"""
|
||||
Описывает один из четырёх фиксированных preview-профилей.
|
||||
"""
|
||||
|
||||
name: str
|
||||
base_width: int
|
||||
base_height: int
|
||||
base_fps: float
|
||||
base_quality: int
|
||||
roi_enabled: bool
|
||||
roi_width: int
|
||||
roi_height: int
|
||||
roi_fps: float
|
||||
roi_quality: int
|
||||
measured_payload_kbps: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProfileState:
|
||||
"""
|
||||
Хранит независимое временное состояние BASE и ROI профиля.
|
||||
"""
|
||||
|
||||
latest_base: np.ndarray | None = None
|
||||
latest_roi: np.ndarray | None = None
|
||||
next_base_time: float = 0.0
|
||||
next_roi_time: float = 0.0
|
||||
last_base_update_time: float = 0.0
|
||||
last_roi_update_time: float = 0.0
|
||||
base_update_count: int = 0
|
||||
roi_update_count: int = 0
|
||||
|
||||
|
||||
def read_video_metadata(
|
||||
source_path: Path,
|
||||
) -> tuple[int, int, float, int, float]:
|
||||
"""
|
||||
Читает и проверяет параметры исходного видео.
|
||||
"""
|
||||
|
||||
if not source_path.exists():
|
||||
raise RuntimeError(
|
||||
f"Исходный видеофайл отсутствует: {source_path}"
|
||||
)
|
||||
|
||||
capture = cv2.VideoCapture(str(source_path))
|
||||
|
||||
if not capture.isOpened():
|
||||
raise RuntimeError(
|
||||
f"OpenCV не смог открыть видео: {source_path}"
|
||||
)
|
||||
|
||||
try:
|
||||
width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
fps = float(capture.get(cv2.CAP_PROP_FPS))
|
||||
frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
finally:
|
||||
capture.release()
|
||||
|
||||
if width <= 0 or height <= 0:
|
||||
raise RuntimeError(
|
||||
"OpenCV вернул некорректное разрешение видео."
|
||||
)
|
||||
|
||||
if fps <= 0.0:
|
||||
raise RuntimeError("FPS исходного видео равен нулю.")
|
||||
|
||||
if frame_count <= 0:
|
||||
raise RuntimeError("Число кадров исходного видео равно нулю.")
|
||||
|
||||
duration_seconds = frame_count / fps
|
||||
|
||||
return width, height, fps, frame_count, duration_seconds
|
||||
|
||||
|
||||
def build_profiles() -> list[PreviewProfile]:
|
||||
"""
|
||||
Создаёт ровно четыре согласованных preview-профиля.
|
||||
"""
|
||||
|
||||
profiles = [
|
||||
PreviewProfile(
|
||||
name="baseline",
|
||||
base_width=320,
|
||||
base_height=180,
|
||||
base_fps=2.0,
|
||||
base_quality=40,
|
||||
roi_enabled=False,
|
||||
roi_width=0,
|
||||
roi_height=0,
|
||||
roi_fps=0.0,
|
||||
roi_quality=0,
|
||||
measured_payload_kbps=80.27,
|
||||
),
|
||||
PreviewProfile(
|
||||
name="ROI normal",
|
||||
base_width=240,
|
||||
base_height=135,
|
||||
base_fps=1.0,
|
||||
base_quality=25,
|
||||
roi_enabled=True,
|
||||
roi_width=320,
|
||||
roi_height=180,
|
||||
roi_fps=2.0,
|
||||
roi_quality=35,
|
||||
measured_payload_kbps=93.28,
|
||||
),
|
||||
PreviewProfile(
|
||||
name="ROI economy",
|
||||
base_width=160,
|
||||
base_height=90,
|
||||
base_fps=1.0,
|
||||
base_quality=25,
|
||||
roi_enabled=True,
|
||||
roi_width=320,
|
||||
roi_height=180,
|
||||
roi_fps=2.0,
|
||||
roi_quality=35,
|
||||
measured_payload_kbps=85.19,
|
||||
),
|
||||
PreviewProfile(
|
||||
name="ROI degraded channel",
|
||||
base_width=240,
|
||||
base_height=135,
|
||||
base_fps=1.0,
|
||||
base_quality=20,
|
||||
roi_enabled=True,
|
||||
roi_width=320,
|
||||
roi_height=180,
|
||||
roi_fps=1.0,
|
||||
roi_quality=30,
|
||||
measured_payload_kbps=50.49,
|
||||
),
|
||||
]
|
||||
|
||||
if len(profiles) != 4:
|
||||
raise RuntimeError(
|
||||
"Preview Lab027 должно содержать ровно четыре профиля."
|
||||
)
|
||||
|
||||
return profiles
|
||||
|
||||
|
||||
def normalized_roi_to_pixels(
|
||||
width: int,
|
||||
height: int,
|
||||
) -> tuple[int, int, int, int]:
|
||||
"""
|
||||
Переводит фиксированные нормализованные координаты ROI в пиксели.
|
||||
|
||||
Результат задаёт полуоткрытый прямоугольник:
|
||||
x_min, y_min, x_max, y_max.
|
||||
"""
|
||||
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("Размер кадра должен быть положительным.")
|
||||
|
||||
x_min = int(round(width * ROI_X_MIN))
|
||||
x_max = int(round(width * ROI_X_MAX))
|
||||
y_min = int(round(height * ROI_Y_MIN))
|
||||
y_max = int(round(height * ROI_Y_MAX))
|
||||
|
||||
x_min = min(max(x_min, 0), width - 1)
|
||||
x_max = min(max(x_max, x_min + 1), width)
|
||||
y_min = min(max(y_min, 0), height - 1)
|
||||
y_max = min(max(y_max, y_min + 1), height)
|
||||
|
||||
return x_min, y_min, x_max, y_max
|
||||
|
||||
|
||||
def should_update(
|
||||
current_time: float,
|
||||
next_update_time: float,
|
||||
epsilon_seconds: float = FRAME_TIME_EPSILON_SECONDS,
|
||||
) -> bool:
|
||||
"""
|
||||
Проверяет наступление времени очередного обновления потока.
|
||||
"""
|
||||
|
||||
return (
|
||||
current_time + epsilon_seconds
|
||||
>= next_update_time
|
||||
)
|
||||
|
||||
|
||||
def encode_decode_grayscale_jpeg(
|
||||
gray_frame: np.ndarray,
|
||||
jpeg_quality: int,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Кодирует grayscale JPEG в памяти и декодирует его обратно.
|
||||
"""
|
||||
|
||||
if gray_frame.ndim != 2:
|
||||
raise RuntimeError(
|
||||
"JPEG preview должен получать grayscale-кадр."
|
||||
)
|
||||
|
||||
encoding_ok, encoded = cv2.imencode(
|
||||
".jpg",
|
||||
gray_frame,
|
||||
[cv2.IMWRITE_JPEG_QUALITY, jpeg_quality],
|
||||
)
|
||||
|
||||
if not encoding_ok or encoded is None or encoded.size == 0:
|
||||
raise RuntimeError("OpenCV не смог закодировать JPEG.")
|
||||
|
||||
decoded = cv2.imdecode(
|
||||
encoded,
|
||||
cv2.IMREAD_GRAYSCALE,
|
||||
)
|
||||
|
||||
if decoded is None or decoded.shape != gray_frame.shape:
|
||||
raise RuntimeError(
|
||||
"Декодированный JPEG имеет некорректный размер."
|
||||
)
|
||||
|
||||
return decoded
|
||||
|
||||
|
||||
def encode_decode_base(
|
||||
source_frame: np.ndarray,
|
||||
profile: PreviewProfile,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Формирует очередное декодированное состояние BASE.
|
||||
"""
|
||||
|
||||
source_gray = cv2.cvtColor(
|
||||
source_frame,
|
||||
cv2.COLOR_BGR2GRAY,
|
||||
)
|
||||
resized_base = cv2.resize(
|
||||
source_gray,
|
||||
(profile.base_width, profile.base_height),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
)
|
||||
|
||||
return encode_decode_grayscale_jpeg(
|
||||
resized_base,
|
||||
profile.base_quality,
|
||||
)
|
||||
|
||||
|
||||
def encode_decode_roi(
|
||||
source_frame: np.ndarray,
|
||||
source_roi: tuple[int, int, int, int],
|
||||
profile: PreviewProfile,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Вырезает ROI исходного BGR-кадра и формирует JPEG-состояние.
|
||||
"""
|
||||
|
||||
if not profile.roi_enabled:
|
||||
raise RuntimeError(
|
||||
"Нельзя кодировать ROI для отключённого ROI-профиля."
|
||||
)
|
||||
|
||||
x_min, y_min, x_max, y_max = source_roi
|
||||
roi_bgr = source_frame[y_min:y_max, x_min:x_max]
|
||||
|
||||
if roi_bgr.size == 0:
|
||||
raise RuntimeError("Вырезана пустая область ROI.")
|
||||
|
||||
roi_gray = cv2.cvtColor(
|
||||
roi_bgr,
|
||||
cv2.COLOR_BGR2GRAY,
|
||||
)
|
||||
resized_roi = cv2.resize(
|
||||
roi_gray,
|
||||
(profile.roi_width, profile.roi_height),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
)
|
||||
|
||||
return encode_decode_grayscale_jpeg(
|
||||
resized_roi,
|
||||
profile.roi_quality,
|
||||
)
|
||||
|
||||
|
||||
def reconstruct_panel(
|
||||
profile: PreviewProfile,
|
||||
state: ProfileState,
|
||||
panel_roi: tuple[int, int, int, int],
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Восстанавливает одну grayscale-панель размером 640x360.
|
||||
"""
|
||||
|
||||
if state.latest_base is None:
|
||||
raise RuntimeError("BASE-состояние ещё не создано.")
|
||||
|
||||
reconstructed = cv2.resize(
|
||||
state.latest_base,
|
||||
(PANEL_WIDTH, PANEL_HEIGHT),
|
||||
interpolation=cv2.INTER_LINEAR,
|
||||
)
|
||||
|
||||
if profile.roi_enabled:
|
||||
if state.latest_roi is None:
|
||||
raise RuntimeError("ROI-состояние ещё не создано.")
|
||||
|
||||
x_min, y_min, x_max, y_max = panel_roi
|
||||
resized_roi = cv2.resize(
|
||||
state.latest_roi,
|
||||
(x_max - x_min, y_max - y_min),
|
||||
interpolation=cv2.INTER_LINEAR,
|
||||
)
|
||||
reconstructed[y_min:y_max, x_min:x_max] = resized_roi
|
||||
|
||||
return reconstructed
|
||||
|
||||
|
||||
def draw_text_line(
|
||||
image: np.ndarray,
|
||||
text: str,
|
||||
y_position: int,
|
||||
text_color: tuple[int, int, int] = (255, 255, 255),
|
||||
) -> None:
|
||||
"""
|
||||
Рисует одну ASCII-строку служебной информации OpenCV.
|
||||
"""
|
||||
|
||||
cv2.putText(
|
||||
image,
|
||||
text,
|
||||
(10, y_position),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
0.46,
|
||||
text_color,
|
||||
1,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
|
||||
|
||||
def draw_panel_information(
|
||||
reconstructed_gray: np.ndarray,
|
||||
profile: PreviewProfile,
|
||||
state: ProfileState,
|
||||
current_time: float,
|
||||
panel_roi: tuple[int, int, int, int],
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Добавляет рамку ROI, параметры и индикаторы обновления панели.
|
||||
"""
|
||||
|
||||
panel = cv2.cvtColor(
|
||||
reconstructed_gray,
|
||||
cv2.COLOR_GRAY2BGR,
|
||||
)
|
||||
|
||||
if profile.roi_enabled:
|
||||
cv2.rectangle(
|
||||
panel,
|
||||
(panel_roi[0], panel_roi[1]),
|
||||
(panel_roi[2] - 1, panel_roi[3] - 1),
|
||||
(0, 255, 255),
|
||||
2,
|
||||
)
|
||||
|
||||
overlay = panel.copy()
|
||||
cv2.rectangle(
|
||||
overlay,
|
||||
(0, 0),
|
||||
(PANEL_WIDTH - 1, 142),
|
||||
(0, 0, 0),
|
||||
thickness=-1,
|
||||
)
|
||||
cv2.addWeighted(
|
||||
overlay,
|
||||
0.72,
|
||||
panel,
|
||||
0.28,
|
||||
0.0,
|
||||
panel,
|
||||
)
|
||||
|
||||
base_age = max(
|
||||
0.0,
|
||||
current_time - state.last_base_update_time,
|
||||
)
|
||||
roi_age = max(
|
||||
0.0,
|
||||
current_time - state.last_roi_update_time,
|
||||
)
|
||||
base_is_new = (
|
||||
base_age <= NEW_UPDATE_LABEL_SECONDS
|
||||
)
|
||||
roi_is_new = (
|
||||
profile.roi_enabled
|
||||
and roi_age <= NEW_UPDATE_LABEL_SECONDS
|
||||
)
|
||||
|
||||
draw_text_line(panel, profile.name, 20)
|
||||
draw_text_line(
|
||||
panel,
|
||||
(
|
||||
f"BASE: {profile.base_width}x{profile.base_height}, "
|
||||
f"{profile.base_fps:g} fps, Q{profile.base_quality}"
|
||||
),
|
||||
41,
|
||||
)
|
||||
|
||||
if profile.roi_enabled:
|
||||
roi_text = (
|
||||
f"ROI: {profile.roi_width}x{profile.roi_height}, "
|
||||
f"{profile.roi_fps:g} fps, Q{profile.roi_quality}"
|
||||
)
|
||||
roi_age_text = f"{roi_age:.3f} s"
|
||||
else:
|
||||
roi_text = "ROI: disabled"
|
||||
roi_age_text = "disabled"
|
||||
|
||||
draw_text_line(panel, roi_text, 62)
|
||||
draw_text_line(
|
||||
panel,
|
||||
(
|
||||
f"payload={profile.measured_payload_kbps:.2f} kbps, "
|
||||
f"source time={current_time:.3f} s"
|
||||
),
|
||||
83,
|
||||
)
|
||||
draw_text_line(
|
||||
panel,
|
||||
(
|
||||
f"since BASE={base_age:.3f} s, "
|
||||
f"since ROI={roi_age_text}"
|
||||
),
|
||||
104,
|
||||
)
|
||||
|
||||
update_labels: list[str] = []
|
||||
|
||||
if base_is_new:
|
||||
update_labels.append("NEW BASE")
|
||||
|
||||
if roi_is_new:
|
||||
update_labels.append("NEW ROI")
|
||||
|
||||
if update_labels:
|
||||
draw_text_line(
|
||||
panel,
|
||||
" | ".join(update_labels),
|
||||
130,
|
||||
text_color=(0, 255, 0),
|
||||
)
|
||||
|
||||
return panel
|
||||
|
||||
|
||||
def compose_grid(panels: list[np.ndarray]) -> np.ndarray:
|
||||
"""
|
||||
Объединяет четыре панели в сетку 2x2 размером 1280x720.
|
||||
"""
|
||||
|
||||
if len(panels) != 4:
|
||||
raise RuntimeError(
|
||||
"Для сетки preview требуется ровно четыре панели."
|
||||
)
|
||||
|
||||
for panel in panels:
|
||||
if panel.shape != (PANEL_HEIGHT, PANEL_WIDTH, 3):
|
||||
raise RuntimeError(
|
||||
f"Некорректный размер панели: {panel.shape}"
|
||||
)
|
||||
|
||||
top_row = np.hstack((panels[0], panels[1]))
|
||||
bottom_row = np.hstack((panels[2], panels[3]))
|
||||
grid = np.vstack((top_row, bottom_row))
|
||||
|
||||
if grid.shape != (OUTPUT_HEIGHT, OUTPUT_WIDTH, 3):
|
||||
raise RuntimeError(
|
||||
f"Некорректный размер сетки: {grid.shape}"
|
||||
)
|
||||
|
||||
return grid
|
||||
|
||||
|
||||
def open_preview_writer() -> tuple[cv2.VideoWriter, Path, bool]:
|
||||
"""
|
||||
Открывает MP4 writer либо разрешённый fallback AVI/MJPG.
|
||||
"""
|
||||
|
||||
mp4_writer = cv2.VideoWriter(
|
||||
str(MP4_PREVIEW_PATH),
|
||||
cv2.VideoWriter_fourcc(*"mp4v"),
|
||||
OUTPUT_FPS,
|
||||
(OUTPUT_WIDTH, OUTPUT_HEIGHT),
|
||||
True,
|
||||
)
|
||||
|
||||
if mp4_writer.isOpened():
|
||||
return mp4_writer, MP4_PREVIEW_PATH, False
|
||||
|
||||
mp4_writer.release()
|
||||
|
||||
if MP4_PREVIEW_PATH.exists():
|
||||
MP4_PREVIEW_PATH.unlink()
|
||||
|
||||
avi_writer = cv2.VideoWriter(
|
||||
str(AVI_PREVIEW_PATH),
|
||||
cv2.VideoWriter_fourcc(*"MJPG"),
|
||||
OUTPUT_FPS,
|
||||
(OUTPUT_WIDTH, OUTPUT_HEIGHT),
|
||||
True,
|
||||
)
|
||||
|
||||
if not avi_writer.isOpened():
|
||||
avi_writer.release()
|
||||
|
||||
if AVI_PREVIEW_PATH.exists():
|
||||
AVI_PREVIEW_PATH.unlink()
|
||||
|
||||
raise RuntimeError(
|
||||
"OpenCV не смог открыть ни MP4, ни AVI writer."
|
||||
)
|
||||
|
||||
return avi_writer, AVI_PREVIEW_PATH, True
|
||||
|
||||
|
||||
def create_preview(
|
||||
source_path: Path,
|
||||
profiles: list[PreviewProfile],
|
||||
source_width: int,
|
||||
source_height: int,
|
||||
source_fps: float,
|
||||
expected_frame_count: int,
|
||||
) -> tuple[Path, bool, int, list[ProfileState]]:
|
||||
"""
|
||||
Создаёт preview-видео с одним выходным кадром на исходный кадр.
|
||||
"""
|
||||
|
||||
if len(profiles) != 4:
|
||||
raise RuntimeError(
|
||||
"Ожидалось ровно четыре preview-профиля."
|
||||
)
|
||||
|
||||
PREVIEW_DIRECTORY.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
|
||||
capture = cv2.VideoCapture(str(source_path))
|
||||
|
||||
if not capture.isOpened():
|
||||
raise RuntimeError(
|
||||
f"OpenCV не смог открыть видео: {source_path}"
|
||||
)
|
||||
|
||||
writer, preview_path, fallback_used = open_preview_writer()
|
||||
source_roi = normalized_roi_to_pixels(
|
||||
source_width,
|
||||
source_height,
|
||||
)
|
||||
panel_roi = normalized_roi_to_pixels(
|
||||
PANEL_WIDTH,
|
||||
PANEL_HEIGHT,
|
||||
)
|
||||
states = [
|
||||
ProfileState()
|
||||
for _ in profiles
|
||||
]
|
||||
frame_index = 0
|
||||
|
||||
try:
|
||||
while True:
|
||||
frame_read, source_frame = capture.read()
|
||||
|
||||
if not frame_read:
|
||||
break
|
||||
|
||||
if source_frame is None:
|
||||
raise RuntimeError(
|
||||
f"Получен пустой кадр {frame_index}."
|
||||
)
|
||||
|
||||
if (
|
||||
source_frame.shape[1] != source_width
|
||||
or source_frame.shape[0] != source_height
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Размер кадра отличается от метаданных."
|
||||
)
|
||||
|
||||
current_time = frame_index / source_fps
|
||||
panels: list[np.ndarray] = []
|
||||
|
||||
for profile, state in zip(profiles, states):
|
||||
if should_update(
|
||||
current_time,
|
||||
state.next_base_time,
|
||||
):
|
||||
state.latest_base = encode_decode_base(
|
||||
source_frame,
|
||||
profile,
|
||||
)
|
||||
state.last_base_update_time = current_time
|
||||
state.next_base_time += 1.0 / profile.base_fps
|
||||
state.base_update_count += 1
|
||||
|
||||
if (
|
||||
profile.roi_enabled
|
||||
and should_update(
|
||||
current_time,
|
||||
state.next_roi_time,
|
||||
)
|
||||
):
|
||||
state.latest_roi = encode_decode_roi(
|
||||
source_frame,
|
||||
source_roi,
|
||||
profile,
|
||||
)
|
||||
state.last_roi_update_time = current_time
|
||||
state.next_roi_time += 1.0 / profile.roi_fps
|
||||
state.roi_update_count += 1
|
||||
|
||||
reconstructed = reconstruct_panel(
|
||||
profile,
|
||||
state,
|
||||
panel_roi,
|
||||
)
|
||||
panels.append(
|
||||
draw_panel_information(
|
||||
reconstructed,
|
||||
profile,
|
||||
state,
|
||||
current_time,
|
||||
panel_roi,
|
||||
)
|
||||
)
|
||||
|
||||
writer.write(compose_grid(panels))
|
||||
frame_index += 1
|
||||
|
||||
if (
|
||||
frame_index % 100 == 0
|
||||
or frame_index == expected_frame_count
|
||||
):
|
||||
print(
|
||||
f" Written frames: "
|
||||
f"{frame_index}/{expected_frame_count}"
|
||||
)
|
||||
except Exception:
|
||||
capture.release()
|
||||
writer.release()
|
||||
|
||||
if preview_path.exists():
|
||||
preview_path.unlink()
|
||||
|
||||
raise
|
||||
finally:
|
||||
capture.release()
|
||||
writer.release()
|
||||
|
||||
if frame_index != expected_frame_count:
|
||||
if preview_path.exists():
|
||||
preview_path.unlink()
|
||||
|
||||
raise RuntimeError(
|
||||
"Число записанных кадров не совпало с исходным: "
|
||||
f"{frame_index} != {expected_frame_count}."
|
||||
)
|
||||
|
||||
return (
|
||||
preview_path,
|
||||
fallback_used,
|
||||
frame_index,
|
||||
states,
|
||||
)
|
||||
|
||||
|
||||
def read_frame_at(
|
||||
capture: cv2.VideoCapture,
|
||||
frame_index: int,
|
||||
) -> tuple[bool, tuple[int, ...] | None]:
|
||||
"""
|
||||
Читает один кадр preview по индексу для итоговой проверки.
|
||||
"""
|
||||
|
||||
capture.set(
|
||||
cv2.CAP_PROP_POS_FRAMES,
|
||||
frame_index,
|
||||
)
|
||||
frame_read, frame = capture.read()
|
||||
|
||||
if not frame_read or frame is None:
|
||||
return False, None
|
||||
|
||||
return True, frame.shape
|
||||
|
||||
|
||||
def verify_preview(
|
||||
preview_path: Path,
|
||||
source_frame_count: int,
|
||||
source_duration_seconds: float,
|
||||
) -> tuple[
|
||||
int,
|
||||
int,
|
||||
float,
|
||||
int,
|
||||
float,
|
||||
tuple[bool, tuple[int, ...] | None],
|
||||
tuple[bool, tuple[int, ...] | None],
|
||||
tuple[bool, tuple[int, ...] | None],
|
||||
]:
|
||||
"""
|
||||
Проверяет контейнер, геометрию, FPS, длительность и три кадра.
|
||||
"""
|
||||
|
||||
if not preview_path.exists():
|
||||
raise RuntimeError(
|
||||
f"Preview отсутствует: {preview_path}"
|
||||
)
|
||||
|
||||
if preview_path.stat().st_size <= 0:
|
||||
raise RuntimeError("Preview имеет нулевой размер.")
|
||||
|
||||
capture = cv2.VideoCapture(str(preview_path))
|
||||
|
||||
if not capture.isOpened():
|
||||
raise RuntimeError(
|
||||
f"OpenCV не смог открыть preview: {preview_path}"
|
||||
)
|
||||
|
||||
try:
|
||||
width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
fps = float(capture.get(cv2.CAP_PROP_FPS))
|
||||
frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
|
||||
if fps <= 0.0:
|
||||
raise RuntimeError("FPS preview равен нулю.")
|
||||
|
||||
duration_seconds = frame_count / fps
|
||||
first_frame = read_frame_at(capture, 0)
|
||||
middle_frame = read_frame_at(
|
||||
capture,
|
||||
frame_count // 2,
|
||||
)
|
||||
last_frame = read_frame_at(
|
||||
capture,
|
||||
frame_count - 1,
|
||||
)
|
||||
finally:
|
||||
capture.release()
|
||||
|
||||
if width != OUTPUT_WIDTH or height != OUTPUT_HEIGHT:
|
||||
raise RuntimeError(
|
||||
f"Некорректное разрешение preview: {width}x{height}."
|
||||
)
|
||||
|
||||
if frame_count != source_frame_count:
|
||||
raise RuntimeError(
|
||||
"Число кадров preview не совпало с исходным."
|
||||
)
|
||||
|
||||
allowed_duration_difference = (
|
||||
1.0 / fps + FRAME_TIME_EPSILON_SECONDS
|
||||
)
|
||||
|
||||
if (
|
||||
abs(duration_seconds - source_duration_seconds)
|
||||
> allowed_duration_difference
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Длительность preview отличается более чем на один кадр."
|
||||
)
|
||||
|
||||
for label, frame_result in [
|
||||
("первый", first_frame),
|
||||
("средний", middle_frame),
|
||||
("последний", last_frame),
|
||||
]:
|
||||
if not frame_result[0]:
|
||||
raise RuntimeError(
|
||||
f"Не удалось прочитать {label} кадр preview."
|
||||
)
|
||||
|
||||
return (
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
frame_count,
|
||||
duration_seconds,
|
||||
first_frame,
|
||||
middle_frame,
|
||||
last_frame,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
Создаёт и проверяет динамическое preview Lab027.
|
||||
"""
|
||||
|
||||
print("Reading source video metadata...")
|
||||
(
|
||||
source_width,
|
||||
source_height,
|
||||
source_fps,
|
||||
source_frame_count,
|
||||
source_duration_seconds,
|
||||
) = read_video_metadata(SOURCE_VIDEO_PATH)
|
||||
|
||||
print(f" Source: {SOURCE_VIDEO_PATH}")
|
||||
print(f" Resolution: {source_width}x{source_height}")
|
||||
print(f" FPS: {source_fps:.6f}")
|
||||
print(f" Frames: {source_frame_count}")
|
||||
print(f" Duration: {source_duration_seconds:.6f} s")
|
||||
|
||||
profiles = build_profiles()
|
||||
print("Creating dynamic preview...")
|
||||
(
|
||||
preview_path,
|
||||
fallback_used,
|
||||
written_frame_count,
|
||||
states,
|
||||
) = create_preview(
|
||||
source_path=SOURCE_VIDEO_PATH,
|
||||
profiles=profiles,
|
||||
source_width=source_width,
|
||||
source_height=source_height,
|
||||
source_fps=source_fps,
|
||||
expected_frame_count=source_frame_count,
|
||||
)
|
||||
|
||||
print("Verifying preview...")
|
||||
(
|
||||
preview_width,
|
||||
preview_height,
|
||||
preview_fps,
|
||||
preview_frame_count,
|
||||
preview_duration_seconds,
|
||||
first_frame,
|
||||
middle_frame,
|
||||
last_frame,
|
||||
) = verify_preview(
|
||||
preview_path,
|
||||
source_frame_count,
|
||||
source_duration_seconds,
|
||||
)
|
||||
|
||||
print("")
|
||||
print(f"Preview path: {preview_path}")
|
||||
print(f"Fallback used: {fallback_used}")
|
||||
print(f"File size: {preview_path.stat().st_size} bytes")
|
||||
print(
|
||||
f"Preview resolution: "
|
||||
f"{preview_width}x{preview_height}"
|
||||
)
|
||||
print(f"Preview FPS: {preview_fps:.6f}")
|
||||
print(f"Written frames: {written_frame_count}")
|
||||
print(f"Verified frames: {preview_frame_count}")
|
||||
print(
|
||||
f"Preview duration: "
|
||||
f"{preview_duration_seconds:.6f} s"
|
||||
)
|
||||
print(f"First frame: {first_frame}")
|
||||
print(f"Middle frame: {middle_frame}")
|
||||
print(f"Last frame: {last_frame}")
|
||||
print("")
|
||||
print("Profile update counts:")
|
||||
|
||||
for profile, state in zip(profiles, states):
|
||||
print(
|
||||
f" {profile.name}: "
|
||||
f"BASE={state.base_update_count}, "
|
||||
f"ROI={state.roi_update_count}"
|
||||
)
|
||||
|
||||
print("")
|
||||
print("Lab027 temporal preview completed successfully.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1941
tests/lab027c_synchronous_roi_preview.py
Normal file
1517
tests/lab027d_fps_quality_preview.py
Normal file
1575
tests/lab027e_operator_view_preview.py
Normal file
869
tests/lab028_video_packetization.py
Normal file
@@ -0,0 +1,869 @@
|
||||
"""
|
||||
Lab028. Packetization of synchronous BASE + ROI JPEG video objects.
|
||||
|
||||
The laboratory forms the selected Lab027E profile directly from the source
|
||||
video, keeps every JPEG and packet in memory, verifies the transport layer,
|
||||
and writes only aggregate CSV/report/plot artifacts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import random
|
||||
from typing import Callable
|
||||
|
||||
import cv2
|
||||
import matplotlib
|
||||
import numpy as np
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
from protocol.video_packet import (
|
||||
CompositeFrame,
|
||||
CompositeReassembler,
|
||||
HEADER_FORMAT,
|
||||
HEADER_SIZE,
|
||||
ObjectCRCError,
|
||||
ObjectType,
|
||||
PacketCRCError,
|
||||
VideoPacket,
|
||||
decode_packet,
|
||||
encode_packet,
|
||||
packetize_jpeg,
|
||||
)
|
||||
|
||||
|
||||
SOURCE_VIDEO_PATH = Path("data/raw/lab026_rover_source.mp4")
|
||||
OUTPUT_DIRECTORY = Path("data/processed/lab028")
|
||||
CSV_PATH = OUTPUT_DIRECTORY / "lab028_packet_payload_results.csv"
|
||||
REPORT_PATH = OUTPUT_DIRECTORY / "lab028_report.txt"
|
||||
OVERHEAD_PLOT_PATH = (
|
||||
OUTPUT_DIRECTORY / "lab028_overhead_efficiency.png"
|
||||
)
|
||||
TRAFFIC_PLOT_PATH = (
|
||||
OUTPUT_DIRECTORY / "lab028_packets_wire_bitrate.png"
|
||||
)
|
||||
|
||||
COMPOSITE_FPS = 3.0
|
||||
BASE_WIDTH = 240
|
||||
BASE_HEIGHT = 135
|
||||
BASE_QUALITY = 23
|
||||
ROI_WIDTH = 320
|
||||
ROI_HEIGHT = 180
|
||||
ROI_QUALITY = 33
|
||||
ROI_X_MIN = 0.20
|
||||
ROI_X_MAX = 0.80
|
||||
ROI_Y_MIN = 0.42
|
||||
ROI_Y_MAX = 1.00
|
||||
PAYLOAD_LENGTHS = (64, 128, 256, 512, 1024)
|
||||
FRAME_TIME_EPSILON_SECONDS = 1e-9
|
||||
|
||||
CSV_FIELDS = [
|
||||
"max_payload_bytes",
|
||||
"composite_frames",
|
||||
"mean_base_packets_per_frame",
|
||||
"mean_roi_packets_per_frame",
|
||||
"mean_total_packets_per_frame",
|
||||
"max_total_packets_per_frame",
|
||||
"packets_per_second",
|
||||
"jpeg_payload_bytes",
|
||||
"jpeg_payload_bitrate_kbps",
|
||||
"header_bytes_per_second",
|
||||
"header_bitrate_kbps",
|
||||
"wire_bytes",
|
||||
"wire_bitrate_kbps",
|
||||
"service_data_percent",
|
||||
"efficiency_percent",
|
||||
"mean_wire_packet_bytes",
|
||||
"p95_wire_packet_bytes",
|
||||
"max_wire_packet_bytes",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VideoMetadata:
|
||||
width: int
|
||||
height: int
|
||||
fps: float
|
||||
frame_count: int
|
||||
duration_seconds: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EncodedComposite:
|
||||
composite_frame_id: int
|
||||
source_frame_index: int
|
||||
base_jpeg: bytes
|
||||
roi_jpeg: bytes
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PayloadMetrics:
|
||||
max_payload_bytes: int
|
||||
composite_frames: int
|
||||
mean_base_packets_per_frame: float
|
||||
mean_roi_packets_per_frame: float
|
||||
mean_total_packets_per_frame: float
|
||||
max_total_packets_per_frame: int
|
||||
packets_per_second: float
|
||||
jpeg_payload_bytes: int
|
||||
jpeg_payload_bitrate_kbps: float
|
||||
header_bytes_per_second: float
|
||||
header_bitrate_kbps: float
|
||||
wire_bytes: int
|
||||
wire_bitrate_kbps: float
|
||||
service_data_percent: float
|
||||
efficiency_percent: float
|
||||
mean_wire_packet_bytes: float
|
||||
p95_wire_packet_bytes: float
|
||||
max_wire_packet_bytes: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestResult:
|
||||
name: str
|
||||
passed: bool
|
||||
detail: str
|
||||
|
||||
|
||||
def normalized_roi_to_pixels(
|
||||
width: int,
|
||||
height: int,
|
||||
) -> tuple[int, int, int, int]:
|
||||
"""Use the normalized ROI coordinates from Lab027 through Lab027E."""
|
||||
|
||||
coordinates = (
|
||||
int(round(width * ROI_X_MIN)),
|
||||
int(round(height * ROI_Y_MIN)),
|
||||
int(round(width * ROI_X_MAX)),
|
||||
int(round(height * ROI_Y_MAX)),
|
||||
)
|
||||
x_min, y_min, x_max, y_max = coordinates
|
||||
if not (
|
||||
0 <= x_min < x_max <= width
|
||||
and 0 <= y_min < y_max <= height
|
||||
):
|
||||
raise RuntimeError("calculated ROI is outside the source frame")
|
||||
return coordinates
|
||||
|
||||
|
||||
def encode_grayscale_jpeg(image: np.ndarray, quality: int) -> bytes:
|
||||
"""Encode one grayscale image to an in-memory JPEG."""
|
||||
|
||||
encoded, buffer = cv2.imencode(
|
||||
".jpg",
|
||||
image,
|
||||
[int(cv2.IMWRITE_JPEG_QUALITY), int(quality)],
|
||||
)
|
||||
if not encoded:
|
||||
raise RuntimeError("OpenCV could not encode JPEG")
|
||||
jpeg = buffer.tobytes()
|
||||
if not jpeg:
|
||||
raise RuntimeError("OpenCV produced an empty JPEG")
|
||||
return jpeg
|
||||
|
||||
|
||||
def encode_composite(
|
||||
source_frame: np.ndarray,
|
||||
source_roi: tuple[int, int, int, int],
|
||||
composite_frame_id: int,
|
||||
source_frame_index: int,
|
||||
) -> EncodedComposite:
|
||||
"""Form synchronous BASE and ROI JPEGs from exactly one source frame."""
|
||||
|
||||
grayscale = cv2.cvtColor(source_frame, cv2.COLOR_BGR2GRAY)
|
||||
base = cv2.resize(
|
||||
grayscale,
|
||||
(BASE_WIDTH, BASE_HEIGHT),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
)
|
||||
x_min, y_min, x_max, y_max = source_roi
|
||||
roi = grayscale[y_min:y_max, x_min:x_max]
|
||||
if roi.size == 0:
|
||||
raise RuntimeError("source ROI is empty")
|
||||
roi = cv2.resize(
|
||||
roi,
|
||||
(ROI_WIDTH, ROI_HEIGHT),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
)
|
||||
return EncodedComposite(
|
||||
composite_frame_id=composite_frame_id,
|
||||
source_frame_index=source_frame_index,
|
||||
base_jpeg=encode_grayscale_jpeg(base, BASE_QUALITY),
|
||||
roi_jpeg=encode_grayscale_jpeg(roi, ROI_QUALITY),
|
||||
)
|
||||
|
||||
|
||||
def load_video_profile(
|
||||
source_path: Path,
|
||||
) -> tuple[VideoMetadata, list[EncodedComposite]]:
|
||||
"""Read the video sequentially and select synchronous updates at 3 fps."""
|
||||
|
||||
if not source_path.exists():
|
||||
raise FileNotFoundError(f"source video is missing: {source_path}")
|
||||
capture = cv2.VideoCapture(str(source_path))
|
||||
if not capture.isOpened():
|
||||
raise RuntimeError(f"OpenCV could not open {source_path}")
|
||||
|
||||
width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
fps = float(capture.get(cv2.CAP_PROP_FPS))
|
||||
declared_frame_count = int(
|
||||
capture.get(cv2.CAP_PROP_FRAME_COUNT)
|
||||
)
|
||||
if width <= 0 or height <= 0 or fps <= 0.0:
|
||||
capture.release()
|
||||
raise RuntimeError("invalid source video metadata")
|
||||
|
||||
source_roi = normalized_roi_to_pixels(width, height)
|
||||
selected: list[EncodedComposite] = []
|
||||
source_frame_index = 0
|
||||
next_composite_time = 0.0
|
||||
try:
|
||||
while True:
|
||||
frame_read, source_frame = capture.read()
|
||||
if not frame_read or source_frame is None:
|
||||
break
|
||||
source_time = source_frame_index / fps
|
||||
if (
|
||||
source_time + FRAME_TIME_EPSILON_SECONDS
|
||||
>= next_composite_time
|
||||
):
|
||||
selected.append(
|
||||
encode_composite(
|
||||
source_frame,
|
||||
source_roi,
|
||||
len(selected),
|
||||
source_frame_index,
|
||||
)
|
||||
)
|
||||
next_composite_time += 1.0 / COMPOSITE_FPS
|
||||
source_frame_index += 1
|
||||
finally:
|
||||
capture.release()
|
||||
|
||||
if source_frame_index <= 0 or not selected:
|
||||
raise RuntimeError("source video did not yield frames")
|
||||
if (
|
||||
declared_frame_count > 0
|
||||
and source_frame_index != declared_frame_count
|
||||
):
|
||||
raise RuntimeError(
|
||||
"decoded frame count differs from video metadata: "
|
||||
f"{source_frame_index} != {declared_frame_count}"
|
||||
)
|
||||
metadata = VideoMetadata(
|
||||
width=width,
|
||||
height=height,
|
||||
fps=fps,
|
||||
frame_count=source_frame_index,
|
||||
duration_seconds=source_frame_index / fps,
|
||||
)
|
||||
return metadata, selected
|
||||
|
||||
|
||||
def packets_for_composite(
|
||||
composite: EncodedComposite,
|
||||
max_payload_bytes: int,
|
||||
) -> tuple[list[bytes], list[bytes]]:
|
||||
"""Packetize BASE and ROI separately with one composite frame ID."""
|
||||
|
||||
base_packets = packetize_jpeg(
|
||||
composite.base_jpeg,
|
||||
composite.composite_frame_id,
|
||||
ObjectType.BASE,
|
||||
max_payload_bytes,
|
||||
)
|
||||
roi_packets = packetize_jpeg(
|
||||
composite.roi_jpeg,
|
||||
composite.composite_frame_id,
|
||||
ObjectType.ROI,
|
||||
max_payload_bytes,
|
||||
)
|
||||
return base_packets, roi_packets
|
||||
|
||||
|
||||
def calculate_payload_metrics(
|
||||
composites: list[EncodedComposite],
|
||||
duration_seconds: float,
|
||||
max_payload_bytes: int,
|
||||
) -> PayloadMetrics:
|
||||
"""Calculate actual packet and bitrate statistics for one payload limit."""
|
||||
|
||||
base_counts: list[int] = []
|
||||
roi_counts: list[int] = []
|
||||
total_counts: list[int] = []
|
||||
wire_packet_sizes: list[int] = []
|
||||
for composite in composites:
|
||||
base_packets, roi_packets = packets_for_composite(
|
||||
composite, max_payload_bytes
|
||||
)
|
||||
base_counts.append(len(base_packets))
|
||||
roi_counts.append(len(roi_packets))
|
||||
total_counts.append(len(base_packets) + len(roi_packets))
|
||||
wire_packet_sizes.extend(
|
||||
len(packet) for packet in base_packets + roi_packets
|
||||
)
|
||||
|
||||
jpeg_payload_bytes = sum(
|
||||
len(composite.base_jpeg) + len(composite.roi_jpeg)
|
||||
for composite in composites
|
||||
)
|
||||
packet_count = len(wire_packet_sizes)
|
||||
header_bytes = packet_count * HEADER_SIZE
|
||||
wire_bytes = jpeg_payload_bytes + header_bytes
|
||||
return PayloadMetrics(
|
||||
max_payload_bytes=max_payload_bytes,
|
||||
composite_frames=len(composites),
|
||||
mean_base_packets_per_frame=float(np.mean(base_counts)),
|
||||
mean_roi_packets_per_frame=float(np.mean(roi_counts)),
|
||||
mean_total_packets_per_frame=float(np.mean(total_counts)),
|
||||
max_total_packets_per_frame=max(total_counts),
|
||||
packets_per_second=packet_count / duration_seconds,
|
||||
jpeg_payload_bytes=jpeg_payload_bytes,
|
||||
jpeg_payload_bitrate_kbps=(
|
||||
jpeg_payload_bytes * 8.0 / duration_seconds / 1000.0
|
||||
),
|
||||
header_bytes_per_second=header_bytes / duration_seconds,
|
||||
header_bitrate_kbps=(
|
||||
header_bytes * 8.0 / duration_seconds / 1000.0
|
||||
),
|
||||
wire_bytes=wire_bytes,
|
||||
wire_bitrate_kbps=(
|
||||
wire_bytes * 8.0 / duration_seconds / 1000.0
|
||||
),
|
||||
service_data_percent=header_bytes / wire_bytes * 100.0,
|
||||
efficiency_percent=jpeg_payload_bytes / wire_bytes * 100.0,
|
||||
mean_wire_packet_bytes=float(np.mean(wire_packet_sizes)),
|
||||
p95_wire_packet_bytes=float(
|
||||
np.percentile(wire_packet_sizes, 95)
|
||||
),
|
||||
max_wire_packet_bytes=max(wire_packet_sizes),
|
||||
)
|
||||
|
||||
|
||||
def feed_packets(
|
||||
packets: list[bytes],
|
||||
reassembler: CompositeReassembler | None = None,
|
||||
) -> tuple[list[CompositeFrame], CompositeReassembler]:
|
||||
"""Feed packets and collect every atomically published frame."""
|
||||
|
||||
receiver = reassembler or CompositeReassembler()
|
||||
completed = []
|
||||
for packet in packets:
|
||||
frame = receiver.ingest(packet)
|
||||
if frame is not None:
|
||||
completed.append(frame)
|
||||
return completed, receiver
|
||||
|
||||
|
||||
def assert_frame_matches(
|
||||
frame: CompositeFrame,
|
||||
expected: EncodedComposite,
|
||||
) -> None:
|
||||
if frame.composite_frame_id != expected.composite_frame_id:
|
||||
raise AssertionError("composite frame ID differs")
|
||||
if frame.base_jpeg != expected.base_jpeg:
|
||||
raise AssertionError("BASE JPEG differs byte-for-byte")
|
||||
if frame.roi_jpeg != expected.roi_jpeg:
|
||||
raise AssertionError("ROI JPEG differs byte-for-byte")
|
||||
|
||||
|
||||
def run_functional_tests(
|
||||
composites: list[EncodedComposite],
|
||||
) -> list[TestResult]:
|
||||
"""Run header and all mandatory Lab028 transport checks."""
|
||||
|
||||
if len(composites) < 2:
|
||||
raise RuntimeError("functional checks need two video frames")
|
||||
first = composites[0]
|
||||
second = composites[1]
|
||||
base_packets, roi_packets = packets_for_composite(first, 256)
|
||||
all_packets = base_packets + roi_packets
|
||||
tests: list[tuple[str, Callable[[], str]]] = []
|
||||
|
||||
def header_round_trip() -> str:
|
||||
parsed = decode_packet(all_packets[0])
|
||||
rebuilt = encode_packet(
|
||||
VideoPacket(
|
||||
composite_frame_id=parsed.composite_frame_id,
|
||||
object_type=parsed.object_type,
|
||||
fragment_index=parsed.fragment_index,
|
||||
fragment_count=parsed.fragment_count,
|
||||
jpeg_size=parsed.jpeg_size,
|
||||
object_crc32=parsed.object_crc32,
|
||||
payload=parsed.payload,
|
||||
)
|
||||
)
|
||||
if rebuilt != all_packets[0]:
|
||||
raise AssertionError("serialized bytes changed after round trip")
|
||||
return f"fixed {HEADER_SIZE}-byte header round trip is exact"
|
||||
|
||||
def ordered_lossless() -> str:
|
||||
frames, _ = feed_packets(all_packets)
|
||||
if len(frames) != 1:
|
||||
raise AssertionError("ordered transfer did not emit one frame")
|
||||
assert_frame_matches(frames[0], first)
|
||||
return "BASE and ROI match original JPEG bytes"
|
||||
|
||||
def shuffled_packets() -> str:
|
||||
shuffled = list(all_packets)
|
||||
random.Random(28001).shuffle(shuffled)
|
||||
frames, _ = feed_packets(shuffled)
|
||||
if len(frames) != 1:
|
||||
raise AssertionError("shuffled transfer did not emit one frame")
|
||||
assert_frame_matches(frames[0], first)
|
||||
return "arbitrary packet order reconstructed correctly"
|
||||
|
||||
def duplicate_packets() -> str:
|
||||
duplicated = list(all_packets)
|
||||
duplicated.extend(
|
||||
[all_packets[0], all_packets[len(base_packets)]]
|
||||
)
|
||||
random.Random(28002).shuffle(duplicated)
|
||||
frames, receiver = feed_packets(duplicated)
|
||||
if len(frames) != 1:
|
||||
raise AssertionError("duplicates changed publication count")
|
||||
assert_frame_matches(frames[0], first)
|
||||
if receiver.duplicate_packets < 2:
|
||||
raise AssertionError("exact duplicates were not counted")
|
||||
return f"{receiver.duplicate_packets} exact duplicates ignored"
|
||||
|
||||
def packet_crc_corruption() -> str:
|
||||
corrupted = bytearray(all_packets[0])
|
||||
corrupted[-1] ^= 0x01
|
||||
receiver = CompositeReassembler()
|
||||
try:
|
||||
receiver.ingest(bytes(corrupted))
|
||||
except PacketCRCError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("corrupted packet passed packet CRC")
|
||||
frames, _ = feed_packets(all_packets[1:], receiver)
|
||||
if frames:
|
||||
raise AssertionError("frame emitted despite rejected packet")
|
||||
return "payload bit flip rejected; composite not emitted"
|
||||
|
||||
def missing_fragment() -> str:
|
||||
missing_packet = roi_packets[len(roi_packets) // 2]
|
||||
missing_index = decode_packet(missing_packet).fragment_index
|
||||
remaining = [
|
||||
packet
|
||||
for packet in all_packets
|
||||
if packet is not missing_packet
|
||||
]
|
||||
frames, receiver = feed_packets(remaining)
|
||||
if frames:
|
||||
raise AssertionError("frame emitted with a missing fragment")
|
||||
missing = receiver.missing_fragments(
|
||||
first.composite_frame_id, ObjectType.ROI
|
||||
)
|
||||
if missing is None or missing_index not in missing:
|
||||
raise AssertionError("missing fragment was not reported")
|
||||
return f"ROI fragment {missing_index} reported missing"
|
||||
|
||||
def object_crc_corruption() -> str:
|
||||
target_position = len(base_packets)
|
||||
parsed = decode_packet(all_packets[target_position])
|
||||
changed_payload = bytearray(parsed.payload)
|
||||
changed_payload[0] ^= 0x01
|
||||
altered = encode_packet(
|
||||
VideoPacket(
|
||||
composite_frame_id=parsed.composite_frame_id,
|
||||
object_type=parsed.object_type,
|
||||
fragment_index=parsed.fragment_index,
|
||||
fragment_count=parsed.fragment_count,
|
||||
jpeg_size=parsed.jpeg_size,
|
||||
object_crc32=parsed.object_crc32,
|
||||
payload=bytes(changed_payload),
|
||||
)
|
||||
)
|
||||
formally_valid = list(all_packets)
|
||||
formally_valid[target_position] = altered
|
||||
receiver = CompositeReassembler()
|
||||
emitted = []
|
||||
object_error_seen = False
|
||||
for packet in formally_valid:
|
||||
try:
|
||||
frame = receiver.ingest(packet)
|
||||
except ObjectCRCError:
|
||||
object_error_seen = True
|
||||
continue
|
||||
if frame is not None:
|
||||
emitted.append(frame)
|
||||
if not object_error_seen:
|
||||
raise AssertionError("object CRC did not detect changed JPEG")
|
||||
if emitted:
|
||||
raise AssertionError("frame emitted after object CRC failure")
|
||||
return "valid packet CRCs still failed full-object CRC"
|
||||
|
||||
def adjacent_frames_do_not_mix() -> str:
|
||||
first_packets = sum(packets_for_composite(first, 256), [])
|
||||
second_packets = sum(packets_for_composite(second, 256), [])
|
||||
interleaved = first_packets + second_packets
|
||||
random.Random(28003).shuffle(interleaved)
|
||||
frames, _ = feed_packets(interleaved)
|
||||
by_id = {frame.composite_frame_id: frame for frame in frames}
|
||||
if set(by_id) != {
|
||||
first.composite_frame_id,
|
||||
second.composite_frame_id,
|
||||
}:
|
||||
raise AssertionError("adjacent frames were lost or mixed")
|
||||
assert_frame_matches(by_id[first.composite_frame_id], first)
|
||||
assert_frame_matches(by_id[second.composite_frame_id], second)
|
||||
return "two interleaved frame IDs remained independent"
|
||||
|
||||
def base_only_is_not_atomic() -> str:
|
||||
frames, receiver = feed_packets(base_packets)
|
||||
if frames:
|
||||
raise AssertionError("BASE-only input emitted a composite")
|
||||
if not receiver.object_is_complete(
|
||||
first.composite_frame_id, ObjectType.BASE
|
||||
):
|
||||
raise AssertionError("complete BASE object was not retained")
|
||||
return "complete BASE retained while composite stayed unpublished"
|
||||
|
||||
tests.extend(
|
||||
[
|
||||
("header_serialization_round_trip", header_round_trip),
|
||||
("ordered_lossless_transfer", ordered_lossless),
|
||||
("random_packet_order", shuffled_packets),
|
||||
("exact_duplicate_packets", duplicate_packets),
|
||||
("packet_crc_corruption", packet_crc_corruption),
|
||||
("missing_fragment", missing_fragment),
|
||||
("object_crc_corruption", object_crc_corruption),
|
||||
("adjacent_frame_isolation", adjacent_frames_do_not_mix),
|
||||
("base_without_roi_atomicity", base_only_is_not_atomic),
|
||||
]
|
||||
)
|
||||
|
||||
results = []
|
||||
for name, test in tests:
|
||||
try:
|
||||
detail = test()
|
||||
except Exception as error:
|
||||
results.append(TestResult(name, False, str(error)))
|
||||
else:
|
||||
results.append(TestResult(name, True, detail))
|
||||
failed = [result for result in results if not result.passed]
|
||||
if failed:
|
||||
details = "; ".join(
|
||||
f"{result.name}: {result.detail}" for result in failed
|
||||
)
|
||||
raise RuntimeError(f"functional transport tests failed: {details}")
|
||||
return results
|
||||
|
||||
|
||||
def save_csv(metrics: list[PayloadMetrics]) -> None:
|
||||
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
|
||||
with CSV_PATH.open("w", encoding="utf-8", newline="") as csv_file:
|
||||
writer = csv.DictWriter(csv_file, fieldnames=CSV_FIELDS)
|
||||
writer.writeheader()
|
||||
for item in metrics:
|
||||
row = {}
|
||||
for field_name in CSV_FIELDS:
|
||||
value = getattr(item, field_name)
|
||||
row[field_name] = (
|
||||
f"{value:.6f}"
|
||||
if isinstance(value, float)
|
||||
else value
|
||||
)
|
||||
writer.writerow(row)
|
||||
|
||||
|
||||
def save_plots(metrics: list[PayloadMetrics]) -> None:
|
||||
payloads = [item.max_payload_bytes for item in metrics]
|
||||
|
||||
figure, axis = plt.subplots(figsize=(9, 5.5))
|
||||
axis.plot(
|
||||
payloads,
|
||||
[item.service_data_percent for item in metrics],
|
||||
marker="o",
|
||||
linewidth=2,
|
||||
label="Service data (header / wire)",
|
||||
)
|
||||
axis.plot(
|
||||
payloads,
|
||||
[item.efficiency_percent for item in metrics],
|
||||
marker="s",
|
||||
linewidth=2,
|
||||
label="Efficiency (JPEG / wire)",
|
||||
)
|
||||
axis.set_xscale("log", base=2)
|
||||
axis.set_xticks(payloads)
|
||||
axis.set_xticklabels([str(value) for value in payloads])
|
||||
axis.set_xlabel("Maximum packet payload, bytes")
|
||||
axis.set_ylabel("Share, %")
|
||||
axis.set_title("Lab028 packet overhead and efficiency")
|
||||
axis.grid(True, alpha=0.3)
|
||||
axis.legend()
|
||||
figure.tight_layout()
|
||||
figure.savefig(OVERHEAD_PLOT_PATH, dpi=160)
|
||||
plt.close(figure)
|
||||
|
||||
figure, packet_axis = plt.subplots(figsize=(9, 5.5))
|
||||
bitrate_axis = packet_axis.twinx()
|
||||
packet_line = packet_axis.plot(
|
||||
payloads,
|
||||
[item.packets_per_second for item in metrics],
|
||||
color="tab:blue",
|
||||
marker="o",
|
||||
linewidth=2,
|
||||
label="Packets/s",
|
||||
)
|
||||
bitrate_lines = bitrate_axis.plot(
|
||||
payloads,
|
||||
[item.wire_bitrate_kbps for item in metrics],
|
||||
color="tab:red",
|
||||
marker="s",
|
||||
linewidth=2,
|
||||
label="Wire bitrate",
|
||||
)
|
||||
bitrate_axis.plot(
|
||||
payloads,
|
||||
[item.jpeg_payload_bitrate_kbps for item in metrics],
|
||||
color="tab:green",
|
||||
linestyle="--",
|
||||
linewidth=2,
|
||||
label="JPEG payload bitrate",
|
||||
)
|
||||
packet_axis.set_xscale("log", base=2)
|
||||
packet_axis.set_xticks(payloads)
|
||||
packet_axis.set_xticklabels([str(value) for value in payloads])
|
||||
packet_axis.set_xlabel("Maximum packet payload, bytes")
|
||||
packet_axis.set_ylabel("Packets per second", color="tab:blue")
|
||||
bitrate_axis.set_ylabel("Bitrate, kbit/s", color="tab:red")
|
||||
packet_axis.set_title("Lab028 packet rate and wire bitrate")
|
||||
packet_axis.grid(True, alpha=0.3)
|
||||
lines = packet_line + bitrate_lines + bitrate_axis.lines[1:]
|
||||
packet_axis.legend(
|
||||
lines,
|
||||
[line.get_label() for line in lines],
|
||||
loc="best",
|
||||
)
|
||||
figure.tight_layout()
|
||||
figure.savefig(TRAFFIC_PLOT_PATH, dpi=160)
|
||||
plt.close(figure)
|
||||
|
||||
|
||||
def metrics_table(metrics: list[PayloadMetrics]) -> list[str]:
|
||||
lines = [
|
||||
(
|
||||
"payload | BASE pkt/frame | ROI pkt/frame | all pkt/frame | "
|
||||
"max pkt/frame | pkt/s | JPEG kbit/s | headers B/s | "
|
||||
"wire kbit/s | service % | efficiency % | packet mean/p95/max"
|
||||
),
|
||||
(
|
||||
"-------:|---------------:|--------------:|--------------:|"
|
||||
"--------------:|------:|------------:|------------:|"
|
||||
"------------:|----------:|-------------:|--------------------:"
|
||||
),
|
||||
]
|
||||
for item in metrics:
|
||||
lines.append(
|
||||
f"{item.max_payload_bytes} | "
|
||||
f"{item.mean_base_packets_per_frame:.3f} | "
|
||||
f"{item.mean_roi_packets_per_frame:.3f} | "
|
||||
f"{item.mean_total_packets_per_frame:.3f} | "
|
||||
f"{item.max_total_packets_per_frame} | "
|
||||
f"{item.packets_per_second:.3f} | "
|
||||
f"{item.jpeg_payload_bitrate_kbps:.3f} | "
|
||||
f"{item.header_bytes_per_second:.3f} | "
|
||||
f"{item.wire_bitrate_kbps:.3f} | "
|
||||
f"{item.service_data_percent:.3f} | "
|
||||
f"{item.efficiency_percent:.3f} | "
|
||||
f"{item.mean_wire_packet_bytes:.1f}/"
|
||||
f"{item.p95_wire_packet_bytes:.1f}/"
|
||||
f"{item.max_wire_packet_bytes}"
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def write_report(
|
||||
metadata: VideoMetadata,
|
||||
composites: list[EncodedComposite],
|
||||
metrics: list[PayloadMetrics],
|
||||
test_results: list[TestResult],
|
||||
) -> None:
|
||||
source_roi = normalized_roi_to_pixels(
|
||||
metadata.width, metadata.height
|
||||
)
|
||||
base_sizes = [len(item.base_jpeg) for item in composites]
|
||||
roi_sizes = [len(item.roi_jpeg) for item in composites]
|
||||
lines = [
|
||||
"Lab028. Пакетирование синхронного BASE + ROI",
|
||||
"",
|
||||
"Профиль и исходные данные",
|
||||
f"- Видео: {SOURCE_VIDEO_PATH}",
|
||||
(
|
||||
f"- Источник: {metadata.width}x{metadata.height}, "
|
||||
f"{metadata.fps:.6f} fps, {metadata.frame_count} кадров, "
|
||||
f"{metadata.duration_seconds:.6f} с."
|
||||
),
|
||||
(
|
||||
f"- ROI: x={ROI_X_MIN:.2f}...{ROI_X_MAX:.2f}, "
|
||||
f"y={ROI_Y_MIN:.2f}...{ROI_Y_MAX:.2f}; "
|
||||
f"пиксели x={source_roi[0]}...{source_roi[2]}, "
|
||||
f"y={source_roi[1]}...{source_roi[3]}."
|
||||
),
|
||||
(
|
||||
f"- Синхронные составные кадры: {len(composites)} "
|
||||
f"при {COMPOSITE_FPS:.3f} fps."
|
||||
),
|
||||
(
|
||||
f"- BASE: {BASE_WIDTH}x{BASE_HEIGHT}, grayscale JPEG Q"
|
||||
f"{BASE_QUALITY}; средний размер {np.mean(base_sizes):.3f} B, "
|
||||
f"min/max {min(base_sizes)}/{max(base_sizes)} B."
|
||||
),
|
||||
(
|
||||
f"- ROI: {ROI_WIDTH}x{ROI_HEIGHT}, grayscale JPEG Q"
|
||||
f"{ROI_QUALITY}; средний размер {np.mean(roi_sizes):.3f} B, "
|
||||
f"min/max {min(roi_sizes)}/{max(roi_sizes)} B."
|
||||
),
|
||||
(
|
||||
"- BASE и ROI формируются из одного source frame и имеют "
|
||||
"общий composite_frame_id."
|
||||
),
|
||||
"",
|
||||
"Бинарный заголовок",
|
||||
f"- struct format: {HEADER_FORMAT}",
|
||||
f"- Размер: {HEADER_SIZE} байта; network byte order; padding нет.",
|
||||
(
|
||||
"- Layout: magic[4] @0, version:u8 @4, object_type:u8 @5, "
|
||||
"header_size:u16 @6, composite_frame_id:u32 @8, "
|
||||
"fragment_index:u16 @12, fragment_count:u16 @14, "
|
||||
"payload_length:u16 @16, flags:u16 @18, jpeg_size:u32 @20, "
|
||||
"object_crc32:u32 @24, packet_crc32:u32 @28."
|
||||
),
|
||||
"- object_type: 1=BASE, 2=ROI; flags зарезервирован и равен 0.",
|
||||
"",
|
||||
"CRC32",
|
||||
(
|
||||
"- object_crc32 = zlib.crc32(полный JPEG) & 0xFFFFFFFF; "
|
||||
"значение помещается во все фрагменты объекта и проверяется "
|
||||
"после полной сборки."
|
||||
),
|
||||
(
|
||||
"- packet_crc32: сначала поле packet_crc32 заголовка "
|
||||
"обнуляется, затем CRC считается как "
|
||||
"zlib.crc32(header_with_zero_crc + payload) & 0xFFFFFFFF."
|
||||
),
|
||||
"- Таким образом packet CRC защищает все поля заголовка и payload.",
|
||||
"",
|
||||
"Функциональные проверки",
|
||||
]
|
||||
lines.extend(
|
||||
f"- {'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}"
|
||||
for item in test_results
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"Результаты размеров packet payload",
|
||||
*metrics_table(metrics),
|
||||
"",
|
||||
(
|
||||
"JPEG payload bitrate одинаков для всех строк, потому что "
|
||||
"исходные JPEG не меняются при выборе размера фрагмента."
|
||||
),
|
||||
(
|
||||
"Wire bitrate включает только JPEG payload и 32-байтные "
|
||||
"заголовки каждого пакета."
|
||||
),
|
||||
(
|
||||
"Wire bitrate пока НЕ включает FEC, преамбулу, "
|
||||
"синхронизацию, модуляцию, интервалы, повторные передачи, "
|
||||
"команды управления и телеметрию."
|
||||
),
|
||||
(
|
||||
"Лучший размер packet payload автоматически не выбирается: "
|
||||
"таблица показывает только транспортный компромисс."
|
||||
),
|
||||
"",
|
||||
"Артефакты",
|
||||
f"- CSV: {CSV_PATH}",
|
||||
f"- Overhead/efficiency: {OVERHEAD_PLOT_PATH}",
|
||||
f"- Packet count/wire bitrate: {TRAFFIC_PLOT_PATH}",
|
||||
(
|
||||
"- Промежуточные JPEG и пакеты сохранялись только в памяти; "
|
||||
"бинарные дампы не создавались."
|
||||
),
|
||||
"",
|
||||
]
|
||||
)
|
||||
REPORT_PATH.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
def validate_outputs(metrics: list[PayloadMetrics]) -> None:
|
||||
if len(metrics) != len(PAYLOAD_LENGTHS):
|
||||
raise RuntimeError("not all payload sizes were measured")
|
||||
if [item.max_payload_bytes for item in metrics] != list(
|
||||
PAYLOAD_LENGTHS
|
||||
):
|
||||
raise RuntimeError("payload result order differs")
|
||||
if any(
|
||||
abs(
|
||||
item.service_data_percent
|
||||
+ item.efficiency_percent
|
||||
- 100.0
|
||||
) > 1e-9
|
||||
for item in metrics
|
||||
):
|
||||
raise RuntimeError("overhead and efficiency do not sum to 100%")
|
||||
for path in (
|
||||
CSV_PATH,
|
||||
REPORT_PATH,
|
||||
OVERHEAD_PLOT_PATH,
|
||||
TRAFFIC_PLOT_PATH,
|
||||
):
|
||||
if not path.exists() or path.stat().st_size <= 0:
|
||||
raise RuntimeError(f"missing or empty output: {path}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("Lab028: loading and JPEG-encoding the Lab027E profile...")
|
||||
metadata, composites = load_video_profile(SOURCE_VIDEO_PATH)
|
||||
print(
|
||||
f" source={metadata.width}x{metadata.height}, "
|
||||
f"{metadata.fps:.3f} fps, frames={metadata.frame_count}"
|
||||
)
|
||||
print(f" synchronous composite frames={len(composites)}")
|
||||
|
||||
print("Running functional transport checks...")
|
||||
test_results = run_functional_tests(composites)
|
||||
for result in test_results:
|
||||
print(f" PASS {result.name}: {result.detail}")
|
||||
|
||||
print("Measuring packet payload sizes...")
|
||||
metrics = [
|
||||
calculate_payload_metrics(
|
||||
composites,
|
||||
metadata.duration_seconds,
|
||||
payload_length,
|
||||
)
|
||||
for payload_length in PAYLOAD_LENGTHS
|
||||
]
|
||||
for item in metrics:
|
||||
print(
|
||||
f" payload={item.max_payload_bytes:4d} B: "
|
||||
f"{item.packets_per_second:.3f} packet/s, "
|
||||
f"wire={item.wire_bitrate_kbps:.3f} kbit/s, "
|
||||
f"service={item.service_data_percent:.3f}%"
|
||||
)
|
||||
|
||||
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
|
||||
save_csv(metrics)
|
||||
save_plots(metrics)
|
||||
write_report(metadata, composites, metrics, test_results)
|
||||
validate_outputs(metrics)
|
||||
print(f"CSV: {CSV_PATH}")
|
||||
print(f"Report: {REPORT_PATH}")
|
||||
print(f"Plots: {OVERHEAD_PLOT_PATH}, {TRAFFIC_PLOT_PATH}")
|
||||
print("Lab028 completed successfully.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||