diff --git a/data/processed/lab027/lab027_bitrate_comparison.png b/data/processed/lab027/lab027_bitrate_comparison.png new file mode 100644 index 0000000..8ca25f6 Binary files /dev/null and b/data/processed/lab027/lab027_bitrate_comparison.png differ diff --git a/data/processed/lab027/lab027_candidate_profiles.png b/data/processed/lab027/lab027_candidate_profiles.png new file mode 100644 index 0000000..c9d54d6 Binary files /dev/null and b/data/processed/lab027/lab027_candidate_profiles.png differ diff --git a/data/processed/lab027/lab027_profiles.csv b/data/processed/lab027/lab027_profiles.csv new file mode 100644 index 0000000..5bc330a --- /dev/null +++ b/data/processed/lab027/lab027_profiles.csv @@ -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 diff --git a/data/processed/lab027/lab027_quality_comparison.png b/data/processed/lab027/lab027_quality_comparison.png new file mode 100644 index 0000000..c3fef90 Binary files /dev/null and b/data/processed/lab027/lab027_quality_comparison.png differ diff --git a/data/processed/lab027/lab027_report.txt b/data/processed/lab027/lab027_report.txt new file mode 100644 index 0000000..aeac923 --- /dev/null +++ b/data/processed/lab027/lab027_report.txt @@ -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 +Следующий шаг: ручная оценка пользователем. diff --git a/data/processed/lab027c/lab027c_preview_profiles.csv b/data/processed/lab027c/lab027c_preview_profiles.csv new file mode 100644 index 0000000..fcd6986 --- /dev/null +++ b/data/processed/lab027c/lab027c_preview_profiles.csv @@ -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 diff --git a/data/processed/lab027c/lab027c_preview_report.txt b/data/processed/lab027c/lab027c_preview_report.txt new file mode 100644 index 0000000..ac73cec --- /dev/null +++ b/data/processed/lab027c/lab027c_preview_report.txt @@ -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-видео. diff --git a/data/processed/lab027d/lab027d_profiles.csv b/data/processed/lab027d/lab027d_profiles.csv new file mode 100644 index 0000000..6f68803 --- /dev/null +++ b/data/processed/lab027d/lab027d_profiles.csv @@ -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 diff --git a/data/processed/lab027d/lab027d_report.txt b/data/processed/lab027d/lab027d_report.txt new file mode 100644 index 0000000..29cbac6 --- /dev/null +++ b/data/processed/lab027d/lab027d_report.txt @@ -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-видео. diff --git a/data/processed/lab027e/lab027e_profile.csv b/data/processed/lab027e/lab027e_profile.csv new file mode 100644 index 0000000..d0deaf3 --- /dev/null +++ b/data/processed/lab027e/lab027e_profile.csv @@ -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 diff --git a/data/processed/lab027e/lab027e_report.txt b/data/processed/lab027e/lab027e_report.txt new file mode 100644 index 0000000..4c30e72 --- /dev/null +++ b/data/processed/lab027e/lab027e_report.txt @@ -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. +Следующий шаг: ручная оценка удобства управления пользователем. diff --git a/docs/video_compression_research_lab026_lab027.md b/docs/video_compression_research_lab026_lab027.md new file mode 100644 index 0000000..cc728b6 --- /dev/null +++ b/docs/video_compression_research_lab026_lab027.md @@ -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 и не является +частью передаваемого радиоканального формата. Реальная скорость +автомобиля в исходном видео неизвестна. diff --git a/tests/lab027_roi_multiresolution_benchmark.py b/tests/lab027_roi_multiresolution_benchmark.py new file mode 100644 index 0000000..9d38dc6 --- /dev/null +++ b/tests/lab027_roi_multiresolution_benchmark.py @@ -0,0 +1,1710 @@ +""" +Lab027. Многомасштабная передача изображения: +общий кадр и фиксированная область интереса. + +Лабораторная сравнивает контрольный низкоскоростной JPEG-поток +с восемью профилями, в которых независимо передаются: + +- BASE — уменьшенный общий grayscale-кадр сцены; +- ROI — центральная нижняя область исходного кадра с отдельными + разрешением, частотой обновления и JPEG quality. + +Все JPEG-кадры кодируются и декодируются только в памяти. Радиопротокол, +CRC, FEC, фрагментация и повторные передачи в расчёт не входят. +""" + +from __future__ import annotations + +import csv +import math +from dataclasses import dataclass, field +from pathlib import Path + +import cv2 +import matplotlib.pyplot as plt +import numpy as np + + +SOURCE_VIDEO_PATH = Path("data/raw/lab026_rover_source.mp4") + +OUTPUT_DIRECTORY = Path("data/processed/lab027") +CSV_PATH = OUTPUT_DIRECTORY / "lab027_profiles.csv" +REPORT_PATH = OUTPUT_DIRECTORY / "lab027_report.txt" +BITRATE_PLOT_PATH = ( + OUTPUT_DIRECTORY / "lab027_bitrate_comparison.png" +) +QUALITY_PLOT_PATH = ( + OUTPUT_DIRECTORY / "lab027_quality_comparison.png" +) +CANDIDATE_IMAGE_PATH = ( + OUTPUT_DIRECTORY / "lab027_candidate_profiles.png" +) + +EXPECTED_OUTPUT_PATHS = [ + CSV_PATH, + REPORT_PATH, + BITRATE_PLOT_PATH, + QUALITY_PLOT_PATH, + CANDIDATE_IMAGE_PATH, +] + +BASELINE_BITRATE_KBPS = 80.283660 +FRAME_TIME_EPSILON_SECONDS = 1e-9 +MAXIMUM_PSNR_DB = 100.0 + +ROI_X_MIN = 0.20 +ROI_X_MAX = 0.80 +ROI_Y_MIN = 0.42 +ROI_Y_MAX = 1.00 + +RECONSTRUCTION_WIDTH = 640 +RECONSTRUCTION_HEIGHT = 360 + +CSV_FIELD_NAMES = [ + "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", +] + + +@dataclass(frozen=True) +class RoiProfile: + """ + Описывает один фиксированный профиль передачи BASE и ROI. + """ + + profile_name: str + base_width: int + base_height: int + base_fps: float + base_quality: int + roi_enabled: bool + roi_width: int = 0 + roi_height: int = 0 + roi_fps: float = 0.0 + roi_quality: int = 0 + + +@dataclass(frozen=True) +class RoiResult: + """ + Хранит итоговые метрики одного профиля Lab027. + """ + + profile_name: str + base_width: int + base_height: int + base_fps: float + base_quality: int + roi_enabled: bool + roi_x_min: float + roi_y_min: float + roi_x_max: float + roi_y_max: float + roi_width: int + roi_height: int + roi_fps: float + roi_quality: int + source_duration_s: float + selected_base_frames: int + selected_roi_frames: int + actual_base_fps: float + actual_roi_fps: float + total_base_bytes: int + total_roi_bytes: int + total_payload_bytes: int + mean_base_frame_bytes: float + mean_roi_frame_bytes: float + p95_base_frame_bytes: float + p95_roi_frame_bytes: float + max_base_frame_bytes: int + max_roi_frame_bytes: int + base_bitrate_kbps: float + roi_bitrate_kbps: float + total_payload_bitrate_kbps: float + baseline_bitrate_kbps: float + difference_from_baseline_kbps: float + percent_of_baseline: float + mean_base_jpeg_psnr_db: float + mean_roi_jpeg_psnr_db: float | None + mean_reconstructed_full_psnr_db: float + mean_reconstructed_roi_psnr_db: float | None + + +@dataclass +class ProfileAccumulator: + """ + Накапливает размеры, PSNR и последнее состояние потоков. + """ + + next_base_time: float = 0.0 + next_roi_time: float = 0.0 + base_sizes_bytes: list[int] = field(default_factory=list) + roi_sizes_bytes: list[int] = field(default_factory=list) + base_jpeg_psnr_values_db: list[float] = field( + default_factory=list + ) + roi_jpeg_psnr_values_db: list[float] = field( + default_factory=list + ) + reconstructed_full_psnr_values_db: list[float] = field( + default_factory=list + ) + reconstructed_roi_psnr_values_db: list[float] = field( + default_factory=list + ) + latest_base_frame: np.ndarray | None = None + latest_roi_frame: np.ndarray | None = None + + +def read_video_metadata( + source_path: Path, +) -> tuple[int, int, float, int, float, int]: + """ + Читает и проверяет основные параметры исходного видео. + """ + + 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)) + source_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 source_fps <= 0.0: + raise RuntimeError("FPS исходного видео равен нулю.") + + if frame_count <= 0: + raise RuntimeError("Число кадров исходного видео равно нулю.") + + duration_seconds = frame_count / source_fps + file_size_bytes = source_path.stat().st_size + + if duration_seconds <= 0.0 or file_size_bytes <= 0: + raise RuntimeError( + "Длительность или размер исходного видео некорректны." + ) + + return ( + width, + height, + source_fps, + frame_count, + duration_seconds, + file_size_bytes, + ) + + +def build_profiles() -> list[RoiProfile]: + """ + Создаёт ровно девять заданных в Lab027 профилей. + """ + + profiles = [ + RoiProfile( + "baseline_320x180_gray_2fps_q40", + 320, + 180, + 2.0, + 40, + False, + ), + RoiProfile( + "base240_1fps_q25_roi320_2fps_q35", + 240, + 135, + 1.0, + 25, + True, + 320, + 180, + 2.0, + 35, + ), + RoiProfile( + "base240_0_5fps_q25_roi320_2fps_q35", + 240, + 135, + 0.5, + 25, + True, + 320, + 180, + 2.0, + 35, + ), + RoiProfile( + "base160_1fps_q25_roi320_2fps_q35", + 160, + 90, + 1.0, + 25, + True, + 320, + 180, + 2.0, + 35, + ), + RoiProfile( + "base240_1fps_q25_roi448_1fps_q30", + 240, + 135, + 1.0, + 25, + True, + 448, + 252, + 1.0, + 30, + ), + RoiProfile( + "base240_0_5fps_q25_roi448_1fps_q30", + 240, + 135, + 0.5, + 25, + True, + 448, + 252, + 1.0, + 30, + ), + RoiProfile( + "base320_1fps_q30_roi320_1fps_q35", + 320, + 180, + 1.0, + 30, + True, + 320, + 180, + 1.0, + 35, + ), + RoiProfile( + "base240_1fps_q20_roi320_1fps_q30", + 240, + 135, + 1.0, + 20, + True, + 320, + 180, + 1.0, + 30, + ), + RoiProfile( + "base160_0_5fps_q20_roi320_2fps_q30", + 160, + 90, + 0.5, + 20, + True, + 320, + 180, + 2.0, + 30, + ), + ] + + if len(profiles) != 9: + raise RuntimeError("Lab027 должна содержать ровно 9 профилей.") + + if len({profile.profile_name for profile in profiles}) != 9: + raise RuntimeError("Имена профилей 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_sample_frame( + frame_time_seconds: float, + next_sample_time_seconds: float, + epsilon_seconds: float = FRAME_TIME_EPSILON_SECONDS, +) -> bool: + """ + Проверяет наступление времени очередного независимого обновления. + """ + + return ( + frame_time_seconds + epsilon_seconds + >= next_sample_time_seconds + ) + + +def encode_decode_jpeg( + gray_frame: np.ndarray, + jpeg_quality: int, +) -> tuple[int, np.ndarray]: + """ + Кодирует grayscale JPEG в памяти и декодирует его обратно. + """ + + if gray_frame.ndim != 2: + raise RuntimeError( + "JPEG Lab027 должен получать только grayscale-кадр." + ) + + encoded_ok, encoded = cv2.imencode( + ".jpg", + gray_frame, + [cv2.IMWRITE_JPEG_QUALITY, jpeg_quality], + ) + + if not encoded_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 int(encoded.size), decoded + + +def calculate_psnr( + reference_frame: np.ndarray, + reconstructed_frame: np.ndarray, +) -> float: + """ + Рассчитывает PSNR в float64, используя 100 дБ при MSE=0. + """ + + if reference_frame.shape != reconstructed_frame.shape: + raise RuntimeError( + "Нельзя рассчитать PSNR для разных размеров кадров." + ) + + difference = ( + reference_frame.astype(np.float64) + - reconstructed_frame.astype(np.float64) + ) + mean_squared_error = float(np.mean(difference ** 2)) + + if mean_squared_error == 0.0: + return MAXIMUM_PSNR_DB + + return min( + 10.0 + * math.log10( + (255.0 ** 2) / mean_squared_error + ), + MAXIMUM_PSNR_DB, + ) + + +def reconstruct_frame( + decoded_base: np.ndarray, + decoded_roi: np.ndarray | None, + roi_enabled: bool, + reconstruction_roi: tuple[int, int, int, int], +) -> np.ndarray: + """ + Восстанавливает итоговый grayscale-кадр размером 640x360. + """ + + reconstructed = cv2.resize( + decoded_base, + (RECONSTRUCTION_WIDTH, RECONSTRUCTION_HEIGHT), + interpolation=cv2.INTER_LINEAR, + ) + + if not roi_enabled: + return reconstructed + + if decoded_roi is None: + raise RuntimeError( + "Для ROI-профиля отсутствует декодированное состояние ROI." + ) + + x_min, y_min, x_max, y_max = reconstruction_roi + roi_width = x_max - x_min + roi_height = y_max - y_min + resized_roi = cv2.resize( + decoded_roi, + (roi_width, roi_height), + interpolation=cv2.INTER_LINEAR, + ) + reconstructed[y_min:y_max, x_min:x_max] = resized_roi + + return reconstructed + + +def safe_mean(values: list[int]) -> float: + """ + Возвращает среднее списка либо ноль для отсутствующего ROI. + """ + + if not values: + return 0.0 + + return float(np.mean(np.asarray(values, dtype=np.float64))) + + +def safe_percentile(values: list[int], percentile: float) -> float: + """ + Возвращает процентиль списка либо ноль для отсутствующего ROI. + """ + + if not values: + return 0.0 + + return float( + np.percentile( + np.asarray(values, dtype=np.float64), + percentile, + ) + ) + + +def process_profiles( + source_path: Path, + profiles: list[RoiProfile], + source_width: int, + source_height: int, + source_fps: float, + source_frame_count: int, + source_duration_seconds: float, +) -> list[RoiResult]: + """ + Обрабатывает все профили за один последовательный проход по видео. + + BASE и ROI обновляются независимо. Итоговая реконструкция оценивается + на каждом исходном кадре с удержанием последнего декодированного + состояния обоих потоков. + """ + + if len(profiles) != 9: + raise RuntimeError("Ожидалось ровно 9 профилей Lab027.") + + accumulators = { + profile.profile_name: ProfileAccumulator() + for profile in profiles + } + source_roi = normalized_roi_to_pixels( + source_width, + source_height, + ) + reconstruction_roi = normalized_roi_to_pixels( + RECONSTRUCTION_WIDTH, + RECONSTRUCTION_HEIGHT, + ) + + capture = cv2.VideoCapture(str(source_path)) + + if not capture.isOpened(): + raise RuntimeError( + f"OpenCV не смог открыть видео: {source_path}" + ) + + 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( + "Размер прочитанного кадра отличается от метаданных." + ) + + frame_time_seconds = frame_index / source_fps + source_gray = cv2.cvtColor( + source_frame, + cv2.COLOR_BGR2GRAY, + ) + source_roi_gray = source_gray[ + source_roi[1]:source_roi[3], + source_roi[0]:source_roi[2], + ] + reference_full = cv2.resize( + source_gray, + (RECONSTRUCTION_WIDTH, RECONSTRUCTION_HEIGHT), + interpolation=cv2.INTER_AREA, + ) + reference_roi = reference_full[ + reconstruction_roi[1]:reconstruction_roi[3], + reconstruction_roi[0]:reconstruction_roi[2], + ] + + if source_roi_gray.size == 0 or reference_roi.size == 0: + raise RuntimeError("Рассчитана пустая область ROI.") + + for profile in profiles: + accumulator = accumulators[profile.profile_name] + + if should_sample_frame( + frame_time_seconds, + accumulator.next_base_time, + ): + prepared_base = cv2.resize( + source_gray, + (profile.base_width, profile.base_height), + interpolation=cv2.INTER_AREA, + ) + base_size, decoded_base = encode_decode_jpeg( + prepared_base, + profile.base_quality, + ) + accumulator.base_sizes_bytes.append(base_size) + accumulator.base_jpeg_psnr_values_db.append( + calculate_psnr( + prepared_base, + decoded_base, + ) + ) + accumulator.latest_base_frame = decoded_base + accumulator.next_base_time += ( + 1.0 / profile.base_fps + ) + + if ( + profile.roi_enabled + and should_sample_frame( + frame_time_seconds, + accumulator.next_roi_time, + ) + ): + prepared_roi = cv2.resize( + source_roi_gray, + (profile.roi_width, profile.roi_height), + interpolation=cv2.INTER_AREA, + ) + roi_size, decoded_roi = encode_decode_jpeg( + prepared_roi, + profile.roi_quality, + ) + accumulator.roi_sizes_bytes.append(roi_size) + accumulator.roi_jpeg_psnr_values_db.append( + calculate_psnr( + prepared_roi, + decoded_roi, + ) + ) + accumulator.latest_roi_frame = decoded_roi + accumulator.next_roi_time += ( + 1.0 / profile.roi_fps + ) + + if accumulator.latest_base_frame is None: + raise RuntimeError( + "BASE не был инициализирован при времени 0." + ) + + reconstructed = reconstruct_frame( + accumulator.latest_base_frame, + accumulator.latest_roi_frame, + profile.roi_enabled, + reconstruction_roi, + ) + accumulator.reconstructed_full_psnr_values_db.append( + calculate_psnr( + reference_full, + reconstructed, + ) + ) + + if profile.roi_enabled: + reconstructed_roi = reconstructed[ + reconstruction_roi[1]:reconstruction_roi[3], + reconstruction_roi[0]:reconstruction_roi[2], + ] + accumulator.reconstructed_roi_psnr_values_db.append( + calculate_psnr( + reference_roi, + reconstructed_roi, + ) + ) + + frame_index += 1 + + if ( + frame_index % 100 == 0 + or frame_index == source_frame_count + ): + print( + f" Обработано кадров: " + f"{frame_index}/{source_frame_count}" + ) + finally: + capture.release() + + if frame_index == 0: + raise RuntimeError("Не прочитан ни один кадр исходного видео.") + + if frame_index != source_frame_count: + print( + "Предупреждение: фактическое число кадров " + f"{frame_index}, по метаданным {source_frame_count}." + ) + + results: list[RoiResult] = [] + + for profile in profiles: + accumulator = accumulators[profile.profile_name] + + if not accumulator.base_sizes_bytes: + raise RuntimeError( + f"BASE не содержит кадров: {profile.profile_name}" + ) + + if ( + profile.roi_enabled + and not accumulator.roi_sizes_bytes + ): + raise RuntimeError( + f"ROI не содержит кадров: {profile.profile_name}" + ) + + selected_base_frames = len( + accumulator.base_sizes_bytes + ) + selected_roi_frames = len( + accumulator.roi_sizes_bytes + ) + total_base_bytes = int( + sum(accumulator.base_sizes_bytes) + ) + total_roi_bytes = int( + sum(accumulator.roi_sizes_bytes) + ) + total_payload_bytes = ( + total_base_bytes + total_roi_bytes + ) + + actual_base_fps = ( + selected_base_frames / source_duration_seconds + ) + actual_roi_fps = ( + selected_roi_frames / source_duration_seconds + ) + base_bitrate_kbps = ( + total_base_bytes + * 8.0 + / source_duration_seconds + / 1000.0 + ) + roi_bitrate_kbps = ( + total_roi_bytes + * 8.0 + / source_duration_seconds + / 1000.0 + ) + total_payload_bitrate_kbps = ( + total_payload_bytes + * 8.0 + / source_duration_seconds + / 1000.0 + ) + + if total_payload_bitrate_kbps <= 0.0: + raise RuntimeError( + f"Неположительный битрейт: {profile.profile_name}" + ) + + if not math.isclose( + total_payload_bitrate_kbps, + base_bitrate_kbps + roi_bitrate_kbps, + rel_tol=1e-12, + abs_tol=1e-12, + ): + raise RuntimeError( + "Суммарный битрейт не равен сумме BASE и ROI." + ) + + mean_roi_jpeg_psnr = ( + float( + np.mean( + accumulator.roi_jpeg_psnr_values_db + ) + ) + if profile.roi_enabled + else None + ) + mean_reconstructed_roi_psnr = ( + float( + np.mean( + accumulator.reconstructed_roi_psnr_values_db + ) + ) + if profile.roi_enabled + else None + ) + + results.append( + RoiResult( + profile_name=profile.profile_name, + base_width=profile.base_width, + base_height=profile.base_height, + base_fps=profile.base_fps, + base_quality=profile.base_quality, + roi_enabled=profile.roi_enabled, + roi_x_min=( + ROI_X_MIN if profile.roi_enabled else 0.0 + ), + roi_y_min=( + ROI_Y_MIN if profile.roi_enabled else 0.0 + ), + roi_x_max=( + ROI_X_MAX if profile.roi_enabled else 0.0 + ), + roi_y_max=( + ROI_Y_MAX if profile.roi_enabled else 0.0 + ), + roi_width=( + profile.roi_width + if profile.roi_enabled + else 0 + ), + roi_height=( + profile.roi_height + if profile.roi_enabled + else 0 + ), + roi_fps=( + profile.roi_fps + if profile.roi_enabled + else 0.0 + ), + roi_quality=( + profile.roi_quality + if profile.roi_enabled + else 0 + ), + source_duration_s=source_duration_seconds, + selected_base_frames=selected_base_frames, + selected_roi_frames=selected_roi_frames, + actual_base_fps=actual_base_fps, + actual_roi_fps=actual_roi_fps, + total_base_bytes=total_base_bytes, + total_roi_bytes=total_roi_bytes, + total_payload_bytes=total_payload_bytes, + mean_base_frame_bytes=safe_mean( + accumulator.base_sizes_bytes + ), + mean_roi_frame_bytes=safe_mean( + accumulator.roi_sizes_bytes + ), + p95_base_frame_bytes=safe_percentile( + accumulator.base_sizes_bytes, + 95, + ), + p95_roi_frame_bytes=safe_percentile( + accumulator.roi_sizes_bytes, + 95, + ), + max_base_frame_bytes=max( + accumulator.base_sizes_bytes + ), + max_roi_frame_bytes=( + max(accumulator.roi_sizes_bytes) + if accumulator.roi_sizes_bytes + else 0 + ), + base_bitrate_kbps=base_bitrate_kbps, + roi_bitrate_kbps=roi_bitrate_kbps, + total_payload_bitrate_kbps=( + total_payload_bitrate_kbps + ), + baseline_bitrate_kbps=BASELINE_BITRATE_KBPS, + difference_from_baseline_kbps=( + total_payload_bitrate_kbps + - BASELINE_BITRATE_KBPS + ), + percent_of_baseline=( + total_payload_bitrate_kbps + / BASELINE_BITRATE_KBPS + * 100.0 + ), + mean_base_jpeg_psnr_db=float( + np.mean( + accumulator.base_jpeg_psnr_values_db + ) + ), + mean_roi_jpeg_psnr_db=mean_roi_jpeg_psnr, + mean_reconstructed_full_psnr_db=float( + np.mean( + accumulator + .reconstructed_full_psnr_values_db + ) + ), + mean_reconstructed_roi_psnr_db=( + mean_reconstructed_roi_psnr + ), + ) + ) + + if len(results) != 9: + raise RuntimeError("Создано не 9 результатов Lab027.") + + return results + + +def optional_float_text(value: float | None) -> str: + """ + Представляет необязательное значение PSNR для CSV и отчёта. + """ + + if value is None: + return "" + + return f"{value:.6f}" + + +def save_csv(results: list[RoiResult]) -> None: + """ + Сохраняет девять результатов Lab027 в UTF-8 CSV. + """ + + with CSV_PATH.open( + "w", + encoding="utf-8", + newline="", + ) as csv_file: + writer = csv.DictWriter( + csv_file, + fieldnames=CSV_FIELD_NAMES, + ) + writer.writeheader() + + for result in results: + writer.writerow( + { + "profile_name": result.profile_name, + "base_width": result.base_width, + "base_height": result.base_height, + "base_fps": f"{result.base_fps:.6f}", + "base_quality": result.base_quality, + "roi_enabled": int(result.roi_enabled), + "roi_x_min": f"{result.roi_x_min:.6f}", + "roi_y_min": f"{result.roi_y_min:.6f}", + "roi_x_max": f"{result.roi_x_max:.6f}", + "roi_y_max": f"{result.roi_y_max:.6f}", + "roi_width": result.roi_width, + "roi_height": result.roi_height, + "roi_fps": f"{result.roi_fps:.6f}", + "roi_quality": result.roi_quality, + "source_duration_s": ( + f"{result.source_duration_s:.6f}" + ), + "selected_base_frames": ( + result.selected_base_frames + ), + "selected_roi_frames": ( + result.selected_roi_frames + ), + "actual_base_fps": ( + f"{result.actual_base_fps:.6f}" + ), + "actual_roi_fps": ( + f"{result.actual_roi_fps:.6f}" + ), + "total_base_bytes": result.total_base_bytes, + "total_roi_bytes": result.total_roi_bytes, + "total_payload_bytes": ( + result.total_payload_bytes + ), + "mean_base_frame_bytes": ( + f"{result.mean_base_frame_bytes:.3f}" + ), + "mean_roi_frame_bytes": ( + f"{result.mean_roi_frame_bytes:.3f}" + ), + "p95_base_frame_bytes": ( + f"{result.p95_base_frame_bytes:.3f}" + ), + "p95_roi_frame_bytes": ( + f"{result.p95_roi_frame_bytes:.3f}" + ), + "max_base_frame_bytes": ( + result.max_base_frame_bytes + ), + "max_roi_frame_bytes": ( + result.max_roi_frame_bytes + ), + "base_bitrate_kbps": ( + f"{result.base_bitrate_kbps:.6f}" + ), + "roi_bitrate_kbps": ( + f"{result.roi_bitrate_kbps:.6f}" + ), + "total_payload_bitrate_kbps": ( + f"{result.total_payload_bitrate_kbps:.6f}" + ), + "baseline_bitrate_kbps": ( + f"{result.baseline_bitrate_kbps:.6f}" + ), + "difference_from_baseline_kbps": ( + f"{result.difference_from_baseline_kbps:.6f}" + ), + "percent_of_baseline": ( + f"{result.percent_of_baseline:.6f}" + ), + "mean_base_jpeg_psnr_db": ( + f"{result.mean_base_jpeg_psnr_db:.6f}" + ), + "mean_roi_jpeg_psnr_db": optional_float_text( + result.mean_roi_jpeg_psnr_db + ), + "mean_reconstructed_full_psnr_db": ( + f"{result.mean_reconstructed_full_psnr_db:.6f}" + ), + "mean_reconstructed_roi_psnr_db": ( + optional_float_text( + result + .mean_reconstructed_roi_psnr_db + ) + ), + } + ) + + +def short_profile_label(profile_name: str) -> str: + """ + Создаёт компактную многострочную подпись профиля для графиков. + """ + + return profile_name.replace("_roi", "\nroi", 1) + + +def save_bitrate_plot(results: list[RoiResult]) -> None: + """ + Строит составные столбцы BASE/ROI и контрольные линии битрейта. + """ + + x_positions = np.arange(len(results)) + base_values = [ + result.base_bitrate_kbps + for result in results + ] + roi_values = [ + result.roi_bitrate_kbps + for result in results + ] + + figure, axes = plt.subplots(figsize=(15, 8)) + axes.bar( + x_positions, + base_values, + label="BASE bitrate", + ) + axes.bar( + x_positions, + roi_values, + bottom=base_values, + label="ROI bitrate", + ) + axes.axhline( + BASELINE_BITRATE_KBPS, + linestyle="--", + label=f"Baseline {BASELINE_BITRATE_KBPS:.6f} kbps", + ) + axes.axhline( + 100.0, + linestyle=":", + label="100 kbps", + ) + + for position, result in zip(x_positions, results): + axes.annotate( + f"{result.total_payload_bitrate_kbps:.1f}", + ( + position, + result.total_payload_bitrate_kbps, + ), + xytext=(0, 5), + textcoords="offset points", + ha="center", + fontsize=8, + ) + + axes.set_xticks( + x_positions, + [ + short_profile_label(result.profile_name) + for result in results + ], + rotation=35, + ha="right", + ) + axes.set_ylabel("Полезный битрейт, кбит/с") + axes.set_title( + "Lab027. Распределение payload bitrate между BASE и ROI" + ) + axes.legend() + axes.grid(True, axis="y") + figure.tight_layout() + figure.savefig(BITRATE_PLOT_PATH, dpi=160) + plt.close(figure) + + +def save_quality_plot(results: list[RoiResult]) -> None: + """ + Сравнивает средний PSNR полной реконструкции и области ROI. + """ + + x_positions = np.arange(len(results)) + bar_width = 0.38 + full_values = [ + result.mean_reconstructed_full_psnr_db + for result in results + ] + roi_values = [ + ( + result.mean_reconstructed_roi_psnr_db + if result.mean_reconstructed_roi_psnr_db is not None + else np.nan + ) + for result in results + ] + + figure, axes = plt.subplots(figsize=(15, 8)) + axes.bar( + x_positions - bar_width / 2, + full_values, + width=bar_width, + label="Full-frame PSNR", + ) + axes.bar( + x_positions + bar_width / 2, + roi_values, + width=bar_width, + label="ROI PSNR", + ) + axes.set_xticks( + x_positions, + [ + short_profile_label(result.profile_name) + for result in results + ], + rotation=35, + ha="right", + ) + axes.set_ylabel("Средний PSNR реконструкции, дБ") + axes.set_title( + "Lab027. Качество полной реконструкции и области ROI" + ) + axes.legend() + axes.grid(True, axis="y") + figure.tight_layout() + figure.savefig(QUALITY_PLOT_PATH, dpi=160) + plt.close(figure) + + +def read_representative_frame( + source_path: Path, + frame_count: int, +) -> np.ndarray: + """ + Читает кадр около середины ролика для сравнительного изображения. + """ + + capture = cv2.VideoCapture(str(source_path)) + + if not capture.isOpened(): + raise RuntimeError( + f"OpenCV не смог открыть видео: {source_path}" + ) + + middle_index = frame_count // 2 + + try: + capture.set( + cv2.CAP_PROP_POS_FRAMES, + middle_index, + ) + frame_read, frame = capture.read() + finally: + capture.release() + + if not frame_read or frame is None: + raise RuntimeError( + "Не удалось прочитать репрезентативный кадр." + ) + + return frame + + +def representative_reconstruction( + source_frame: np.ndarray, + profile: RoiProfile, + source_roi: tuple[int, int, int, int], + reconstruction_roi: tuple[int, int, int, int], +) -> np.ndarray: + """ + Кодирует BASE/ROI репрезентативного кадра и реконструирует его. + """ + + source_gray = cv2.cvtColor( + source_frame, + cv2.COLOR_BGR2GRAY, + ) + prepared_base = cv2.resize( + source_gray, + (profile.base_width, profile.base_height), + interpolation=cv2.INTER_AREA, + ) + _, decoded_base = encode_decode_jpeg( + prepared_base, + profile.base_quality, + ) + + decoded_roi: np.ndarray | None = None + + if profile.roi_enabled: + source_roi_gray = source_gray[ + source_roi[1]:source_roi[3], + source_roi[0]:source_roi[2], + ] + prepared_roi = cv2.resize( + source_roi_gray, + (profile.roi_width, profile.roi_height), + interpolation=cv2.INTER_AREA, + ) + _, decoded_roi = encode_decode_jpeg( + prepared_roi, + profile.roi_quality, + ) + + return reconstruct_frame( + decoded_base, + decoded_roi, + profile.roi_enabled, + reconstruction_roi, + ) + + +def save_candidate_image( + source_path: Path, + profiles: list[RoiProfile], + results: list[RoiResult], + source_width: int, + source_height: int, + source_frame_count: int, +) -> None: + """ + Создаёт девять панелей реконструкции репрезентативного кадра. + """ + + source_frame = read_representative_frame( + source_path, + source_frame_count, + ) + source_roi = normalized_roi_to_pixels( + source_width, + source_height, + ) + reconstruction_roi = normalized_roi_to_pixels( + RECONSTRUCTION_WIDTH, + RECONSTRUCTION_HEIGHT, + ) + result_by_name = { + result.profile_name: result + for result in results + } + + figure, axes = plt.subplots( + 3, + 3, + figsize=(18, 12), + ) + + for axes_item, profile in zip(axes.flat, profiles): + result = result_by_name[profile.profile_name] + reconstructed = representative_reconstruction( + source_frame, + profile, + source_roi, + reconstruction_roi, + ) + displayed = reconstructed.copy() + cv2.rectangle( + displayed, + ( + reconstruction_roi[0], + reconstruction_roi[1], + ), + ( + reconstruction_roi[2] - 1, + reconstruction_roi[3] - 1, + ), + 255, + 2, + ) + + if result.mean_reconstructed_roi_psnr_db is None: + roi_psnr_text = "ROI отсутствует" + else: + roi_psnr_text = ( + "ROI PSNR=" + f"{result.mean_reconstructed_roi_psnr_db:.2f} dB" + ) + + axes_item.imshow( + displayed, + cmap="gray", + vmin=0, + vmax=255, + interpolation="nearest", + ) + axes_item.set_title( + f"{profile.profile_name}\n" + f"total={result.total_payload_bitrate_kbps:.2f} kbps, " + f"BASE={result.base_bitrate_kbps:.2f}, " + f"ROI={result.roi_bitrate_kbps:.2f}\n" + f"full PSNR=" + f"{result.mean_reconstructed_full_psnr_db:.2f} dB, " + f"{roi_psnr_text}", + fontsize=8, + ) + axes_item.axis("off") + + figure.suptitle( + "Lab027. Общий кадр и фиксированная область интереса", + fontsize=14, + ) + figure.tight_layout() + figure.savefig(CANDIDATE_IMAGE_PATH, dpi=160) + plt.close(figure) + + +def format_result_line(result: RoiResult) -> str: + """ + Формирует подробную строку одного результата для отчёта. + """ + + roi_jpeg_psnr = ( + f"{result.mean_roi_jpeg_psnr_db:.6f}" + if result.mean_roi_jpeg_psnr_db is not None + else "нет" + ) + reconstructed_roi_psnr = ( + f"{result.mean_reconstructed_roi_psnr_db:.6f}" + if result.mean_reconstructed_roi_psnr_db is not None + else "нет" + ) + + return ( + f"{result.profile_name}: " + f"BASE={result.base_width}x{result.base_height}, " + f"{result.base_fps:.3f} fps, Q{result.base_quality}, " + f"base_frames={result.selected_base_frames}, " + f"base={result.base_bitrate_kbps:.6f} kbit/s; " + f"ROI=" + f"{result.roi_width}x{result.roi_height}, " + f"{result.roi_fps:.3f} fps, Q{result.roi_quality}, " + f"roi_frames={result.selected_roi_frames}, " + f"roi={result.roi_bitrate_kbps:.6f} kbit/s; " + f"total={result.total_payload_bitrate_kbps:.6f} kbit/s, " + f"baseline_diff=" + f"{result.difference_from_baseline_kbps:.6f}, " + f"baseline_percent={result.percent_of_baseline:.3f}%, " + f"BASE_JPEG_PSNR=" + f"{result.mean_base_jpeg_psnr_db:.6f} dB, " + f"ROI_JPEG_PSNR={roi_jpeg_psnr} dB, " + f"full_reconstructed_PSNR=" + f"{result.mean_reconstructed_full_psnr_db:.6f} dB, " + f"ROI_reconstructed_PSNR=" + f"{reconstructed_roi_psnr} dB" + ) + + +def append_result_group( + lines: list[str], + heading: str, + results: list[RoiResult], +) -> None: + """ + Добавляет в отчёт группу результатов либо отметку об отсутствии. + """ + + lines.append(heading) + + if not results: + lines.append("Нет.") + else: + lines.extend(format_result_line(result) for result in results) + + lines.append("") + + +def write_report( + results: list[RoiResult], + source_width: int, + source_height: int, + source_fps: float, + source_frame_count: int, + source_duration_seconds: float, + source_file_size_bytes: int, +) -> None: + """ + Сохраняет подробный UTF-8 отчёт Lab027. + """ + + under_baseline = [ + result + for result in results + if result.total_payload_bitrate_kbps + <= BASELINE_BITRATE_KBPS + 1e-9 + ] + under_100 = [ + result + for result in results + if result.total_payload_bitrate_kbps <= 100.0 + ] + roi_under_100 = [ + result + for result in under_100 + if result.roi_enabled + ] + + maximum_roi_resolution = ( + max( + roi_under_100, + key=lambda item: ( + item.roi_width * item.roi_height, + item.mean_reconstructed_roi_psnr_db + if item.mean_reconstructed_roi_psnr_db is not None + else -math.inf, + ), + ) + if roi_under_100 + else None + ) + best_roi_psnr = ( + max( + roi_under_100, + key=lambda item: ( + item.mean_reconstructed_roi_psnr_db + if item.mean_reconstructed_roi_psnr_db is not None + else -math.inf + ), + ) + if roi_under_100 + else None + ) + best_full_psnr = ( + max( + under_100, + key=lambda item: ( + item.mean_reconstructed_full_psnr_db + ), + ) + if under_100 + else None + ) + source_roi = normalized_roi_to_pixels( + source_width, + source_height, + ) + + lines = [ + "Lab027. Многомасштабная передача изображения: " + "общий кадр и область интереса", + "", + "Цель: проверить совместную передачу общего кадра и " + "фиксированной ROI при payload около 80–100 кбит/с.", + f"Исходное видео: {SOURCE_VIDEO_PATH}", + f"Размер файла: {source_file_size_bytes} байт", + f"Исходное разрешение: {source_width}×{source_height}", + f"Исходный FPS: {source_fps:.6f}", + f"Число кадров: {source_frame_count}", + f"Длительность: {source_duration_seconds:.6f} с", + "", + "BASE: полный grayscale-кадр пониженного разрешения " + "с собственной частотой и JPEG quality.", + "ROI: фиксированная центральная нижняя область исходного " + "grayscale-кадра с независимыми разрешением, частотой " + "и JPEG quality.", + "Нормализованные координаты ROI: " + f"x={ROI_X_MIN:.2f}...{ROI_X_MAX:.2f}, " + f"y={ROI_Y_MIN:.2f}...{ROI_Y_MAX:.2f}.", + "Пиксельные координаты ROI исходного кадра: " + f"x={source_roi[0]}...{source_roi[2]}, " + f"y={source_roi[1]}...{source_roi[3]}.", + "", + "Контрольный профиль: 320×180 grayscale, 2 fps, " + "JPEG Q40.", + f"Контрольный baseline: {BASELINE_BITRATE_KBPS:.6f} " + "кбит/с.", + "", + "Результаты всех девяти профилей:", + ] + lines.extend(format_result_line(result) for result in results) + lines.append("") + + append_result_group( + lines, + "Профили до 80.283660 кбит/с:", + under_baseline, + ) + append_result_group( + lines, + "Профили до 100 кбит/с:", + under_100, + ) + append_result_group( + lines, + "Профиль с максимальным ROI-разрешением до 100 кбит/с:", + ( + [maximum_roi_resolution] + if maximum_roi_resolution is not None + else [] + ), + ) + append_result_group( + lines, + "Профиль с максимальным ROI PSNR до 100 кбит/с:", + [best_roi_psnr] if best_roi_psnr is not None else [], + ) + append_result_group( + lines, + "Профиль с максимальным full-frame PSNR " + "до 100 кбит/с:", + [best_full_psnr] if best_full_psnr is not None else [], + ) + + lines.append("Разделение битрейта BASE/ROI:") + for result in results: + lines.append( + f"{result.profile_name}: " + f"BASE={result.base_bitrate_kbps:.6f} кбит/с, " + f"ROI={result.roi_bitrate_kbps:.6f} кбит/с, " + f"total={result.total_payload_bitrate_kbps:.6f} " + "кбит/с." + ) + + lines.extend( + [ + "", + "Фиксированная ROI не гарантирует попадания препятствия " + "в область интереса.", + "PSNR не заменяет ручную оценку пригодности изображения.", + "Нельзя автоматически объявлять профиль безопасным " + "для управления ровером.", + "Радиопротокол, радиозаголовки, CRC, FEC, " + "фрагментация и повторные передачи не учтены.", + f"Сравнительное изображение: {CANDIDATE_IMAGE_PATH}", + "Следующий шаг: ручная оценка пользователем.", + "", + ] + ) + + REPORT_PATH.write_text( + "\n".join(lines), + encoding="utf-8", + ) + + +def validate_output_files() -> None: + """ + Проверяет наличие и ненулевой размер пяти результатов Lab027. + """ + + for path in EXPECTED_OUTPUT_PATHS: + if not path.exists(): + raise RuntimeError( + f"Не создан ожидаемый результат: {path}" + ) + + if path.stat().st_size <= 0: + raise RuntimeError( + f"Создан пустой результат: {path}" + ) + + +def main() -> None: + """ + Выполняет полный эксперимент Lab027. + """ + + print("Проверка исходного видео Lab027...") + ( + source_width, + source_height, + source_fps, + source_frame_count, + source_duration_seconds, + source_file_size_bytes, + ) = read_video_metadata(SOURCE_VIDEO_PATH) + + print(f" Путь: {SOURCE_VIDEO_PATH}") + print(f" Размер: {source_file_size_bytes} байт") + print( + f" Разрешение: {source_width}x{source_height}" + ) + print(f" FPS: {source_fps:.6f}") + print(f" Кадров: {source_frame_count}") + print(f" Длительность: {source_duration_seconds:.6f} с") + + profiles = build_profiles() + print(f"Профилей: {len(profiles)}") + + OUTPUT_DIRECTORY.mkdir( + parents=True, + exist_ok=True, + ) + + print("Обработка BASE и ROI...") + results = process_profiles( + source_path=SOURCE_VIDEO_PATH, + profiles=profiles, + source_width=source_width, + source_height=source_height, + source_fps=source_fps, + source_frame_count=source_frame_count, + source_duration_seconds=source_duration_seconds, + ) + + print("Сохранение CSV...") + save_csv(results) + print("Построение графика битрейта...") + save_bitrate_plot(results) + print("Построение графика качества...") + save_quality_plot(results) + print("Создание сравнительного изображения...") + save_candidate_image( + source_path=SOURCE_VIDEO_PATH, + profiles=profiles, + results=results, + source_width=source_width, + source_height=source_height, + source_frame_count=source_frame_count, + ) + print("Сохранение отчёта...") + write_report( + results=results, + source_width=source_width, + source_height=source_height, + source_fps=source_fps, + source_frame_count=source_frame_count, + source_duration_seconds=source_duration_seconds, + source_file_size_bytes=source_file_size_bytes, + ) + validate_output_files() + + print("") + print("Результаты:") + for result in results: + print( + f" {result.profile_name}: " + f"BASE={result.base_bitrate_kbps:.6f}, " + f"ROI={result.roi_bitrate_kbps:.6f}, " + f"total={result.total_payload_bitrate_kbps:.6f} " + "кбит/с, " + f"full PSNR=" + f"{result.mean_reconstructed_full_psnr_db:.6f} дБ, " + "ROI PSNR=" + + ( + f"{result.mean_reconstructed_roi_psnr_db:.6f} дБ" + if result.mean_reconstructed_roi_psnr_db + is not None + else "нет" + ) + ) + + print("") + for path in EXPECTED_OUTPUT_PATHS: + print(f" {path} ({path.stat().st_size} байт)") + + print("") + print("Lab027 завершена успешно.") + + +if __name__ == "__main__": + main() diff --git a/tests/lab027_roi_temporal_preview.py b/tests/lab027_roi_temporal_preview.py new file mode 100644 index 0000000..e2da871 --- /dev/null +++ b/tests/lab027_roi_temporal_preview.py @@ -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() diff --git a/tests/lab027c_synchronous_roi_preview.py b/tests/lab027c_synchronous_roi_preview.py new file mode 100644 index 0000000..4dfbf4d --- /dev/null +++ b/tests/lab027c_synchronous_roi_preview.py @@ -0,0 +1,1941 @@ +""" +Lab027C. Динамическое сравнение асинхронного и синхронного BASE + ROI. + +Мини-лабораторная выполняет два детерминированных прохода по одному +исходному видео: + +1. Первый проход измеряет размеры JPEG BASE и ROI для четырёх + фиксированных профилей. +2. Второй проход повторяет расписание, проверяет совпадение размеров + JPEG и создаёт preview с окончательными измеренными битрейтами. + +Асинхронный профиль хранит отдельные состояния BASE и ROI. В трёх +синхронных профилях обе части формируются из одного исходного кадра и +атомарно заменяют отображаемый составной кадр. + +JPEG кодируются и декодируются только в памяти. Скрипт не изменяет +существующие лабораторные и их результаты. +""" + +from __future__ import annotations + +import csv +from dataclasses import dataclass +from pathlib import Path + +import cv2 +import numpy as np + + +SOURCE_VIDEO_PATH = Path("data/raw/lab026_rover_source.mp4") + +OUTPUT_DIRECTORY = Path("data/processed/lab027c") +CSV_PATH = OUTPUT_DIRECTORY / "lab027c_preview_profiles.csv" +REPORT_PATH = OUTPUT_DIRECTORY / "lab027c_preview_report.txt" + +PREVIEW_DIRECTORY = Path("data/raw/lab027c_previews") +MP4_PREVIEW_PATH = ( + PREVIEW_DIRECTORY / "lab027c_synchronous_roi_preview.mp4" +) +AVI_PREVIEW_PATH = ( + PREVIEW_DIRECTORY / "lab027c_synchronous_roi_preview.avi" +) + +UPDATE_MODE_ASYNCHRONOUS = "asynchronous" +UPDATE_MODE_SYNCHRONOUS = "synchronous" + +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_LABEL_DURATION_SECONDS = 0.15 +SYNCHRONOUS_PERIOD_SECONDS = 0.5 +NOT_APPLICABLE = "N/A" + +CSV_FIELD_NAMES = [ + "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", +] + + +@dataclass(frozen=True) +class PreviewProfile: + """ + Описывает один из четырёх фиксированных профилей Lab027C. + """ + + profile_name: str + update_mode: str + base_width: int + base_height: int + base_fps: float + base_quality: int + roi_width: int + roi_height: int + roi_fps: float + roi_quality: int + + +@dataclass +class AsyncState: + """ + Хранит независимые временные состояния асинхронных 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_frame_id: int = -1 + roi_frame_id: int = -1 + base_source_frame_index: int = -1 + roi_source_frame_index: int = -1 + base_timestamp: float = 0.0 + roi_timestamp: float = 0.0 + base_update_count: int = 0 + roi_update_count: int = 0 + + +@dataclass +class SyncState: + """ + Хранит единое атомарное состояние синхронного составного кадра. + """ + + latest_base: np.ndarray | None = None + latest_roi: np.ndarray | None = None + next_composite_time: float = 0.0 + last_composite_update_time: float = 0.0 + composite_frame_id: int = -1 + source_frame_index: int = -1 + timestamp: float = 0.0 + base_update_count: int = 0 + roi_update_count: int = 0 + synchronized_update_count: int = 0 + timestamp_mismatch_count: int = 0 + frame_id_mismatch_count: int = 0 + + +@dataclass(frozen=True) +class ProfileStatistics: + """ + Хранит полные измеренные параметры одного preview-профиля. + """ + + profile_name: str + update_mode: str + base_width: int + base_height: int + base_fps: float + base_quality: int + roi_width: int + roi_height: int + roi_fps: float + roi_quality: int + source_duration_s: float + selected_base_frames: int + selected_roi_frames: int + synchronized_composite_frames: int | None + total_base_bytes: int + total_roi_bytes: int + total_payload_bytes: int + mean_base_frame_bytes: float + mean_roi_frame_bytes: float + mean_composite_frame_bytes: float + p95_composite_frame_bytes: float + max_composite_frame_bytes: int + base_bitrate_kbps: float + roi_bitrate_kbps: float + total_payload_bitrate_kbps: float + timestamp_mismatch_count: int | None + frame_id_mismatch_count: int | None + + +Measurements = dict[str, dict[str, list[int]]] +ProfileState = AsyncState | SyncState + + +def read_video_metadata( + source_path: Path, +) -> tuple[int, int, float, int, float, int]: + """ + Читает и проверяет параметры исходного видео. + """ + + 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 + file_size_bytes = source_path.stat().st_size + + if duration_seconds <= 0.0 or file_size_bytes <= 0: + raise RuntimeError( + "Длительность или размер исходного видео некорректны." + ) + + return ( + width, + height, + fps, + frame_count, + duration_seconds, + file_size_bytes, + ) + + +def build_profiles() -> list[PreviewProfile]: + """ + Создаёт ровно четыре согласованных профиля Lab027C. + """ + + profiles = [ + PreviewProfile( + profile_name="async_reference", + update_mode=UPDATE_MODE_ASYNCHRONOUS, + base_width=240, + base_height=135, + base_fps=1.0, + base_quality=25, + roi_width=320, + roi_height=180, + roi_fps=2.0, + roi_quality=35, + ), + PreviewProfile( + profile_name="sync_base160_q25_roi320_q35", + update_mode=UPDATE_MODE_SYNCHRONOUS, + base_width=160, + base_height=90, + base_fps=2.0, + base_quality=25, + roi_width=320, + roi_height=180, + roi_fps=2.0, + roi_quality=35, + ), + PreviewProfile( + profile_name="sync_base240_q20_roi320_q30", + update_mode=UPDATE_MODE_SYNCHRONOUS, + base_width=240, + base_height=135, + base_fps=2.0, + base_quality=20, + roi_width=320, + roi_height=180, + roi_fps=2.0, + roi_quality=30, + ), + PreviewProfile( + profile_name="sync_base240_q25_roi320_q35", + update_mode=UPDATE_MODE_SYNCHRONOUS, + base_width=240, + base_height=135, + base_fps=2.0, + base_quality=25, + roi_width=320, + roi_height=180, + roi_fps=2.0, + roi_quality=35, + ), + ] + + if len(profiles) != 4: + raise RuntimeError( + "Lab027C должна содержать ровно четыре профиля." + ) + + if len({profile.profile_name for profile in profiles}) != 4: + raise RuntimeError("Имена профилей Lab027C не уникальны.") + + return profiles + + +def normalized_roi_to_pixels( + width: int, + height: int, +) -> tuple[int, int, int, int]: + """ + Переводит нормализованные координаты ROI Lab027 в пиксели. + """ + + 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, +) -> bool: + """ + Проверяет наступление времени очередного обновления. + """ + + return ( + current_time + FRAME_TIME_EPSILON_SECONDS + >= next_update_time + ) + + +def encode_decode_jpeg( + gray_frame: np.ndarray, + jpeg_quality: int, +) -> tuple[int, np.ndarray]: + """ + Кодирует grayscale JPEG в памяти и декодирует его обратно. + """ + + if gray_frame.ndim != 2: + raise RuntimeError( + "JPEG Lab027C должен получать 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 int(encoded.size), decoded + + +def encode_base( + source_frame: np.ndarray, + profile: PreviewProfile, +) -> tuple[int, np.ndarray]: + """ + Формирует JPEG BASE из текущего исходного кадра. + """ + + source_gray = cv2.cvtColor( + source_frame, + cv2.COLOR_BGR2GRAY, + ) + resized = cv2.resize( + source_gray, + (profile.base_width, profile.base_height), + interpolation=cv2.INTER_AREA, + ) + + return encode_decode_jpeg( + resized, + profile.base_quality, + ) + + +def encode_roi( + source_frame: np.ndarray, + source_roi: tuple[int, int, int, int], + profile: PreviewProfile, +) -> tuple[int, np.ndarray]: + """ + Формирует JPEG 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 = cv2.resize( + roi_gray, + (profile.roi_width, profile.roi_height), + interpolation=cv2.INTER_AREA, + ) + + return encode_decode_jpeg( + resized, + profile.roi_quality, + ) + + +def create_measurements( + profiles: list[PreviewProfile], +) -> Measurements: + """ + Создаёт пустые списки размеров для одного прохода. + """ + + return { + profile.profile_name: { + "base": [], + "roi": [], + "composite": [], + } + for profile in profiles + } + + +def create_states( + profiles: list[PreviewProfile], +) -> list[ProfileState]: + """ + Создаёт чистые временные состояния всех профилей. + """ + + states: list[ProfileState] = [] + + for profile in profiles: + if profile.update_mode == UPDATE_MODE_ASYNCHRONOUS: + states.append(AsyncState()) + elif profile.update_mode == UPDATE_MODE_SYNCHRONOUS: + states.append(SyncState()) + else: + raise RuntimeError( + f"Неизвестный режим: {profile.update_mode}" + ) + + return states + + +def update_async_state( + source_frame: np.ndarray, + source_roi: tuple[int, int, int, int], + source_frame_index: int, + current_time: float, + profile: PreviewProfile, + state: AsyncState, + measurements: dict[str, list[int]], +) -> tuple[bool, bool]: + """ + Независимо обновляет BASE и ROI асинхронного reference. + """ + + base_updated = False + roi_updated = False + event_payload_bytes = 0 + + if should_update(current_time, state.next_base_time): + base_size, decoded_base = encode_base( + source_frame, + profile, + ) + state.latest_base = decoded_base + state.base_frame_id += 1 + state.base_source_frame_index = source_frame_index + state.base_timestamp = current_time + state.last_base_update_time = current_time + state.next_base_time += 1.0 / profile.base_fps + state.base_update_count += 1 + measurements["base"].append(base_size) + event_payload_bytes += base_size + base_updated = True + + if should_update(current_time, state.next_roi_time): + roi_size, decoded_roi = encode_roi( + source_frame, + source_roi, + profile, + ) + state.latest_roi = decoded_roi + state.roi_frame_id += 1 + state.roi_source_frame_index = source_frame_index + state.roi_timestamp = current_time + state.last_roi_update_time = current_time + state.next_roi_time += 1.0 / profile.roi_fps + state.roi_update_count += 1 + measurements["roi"].append(roi_size) + event_payload_bytes += roi_size + roi_updated = True + + if event_payload_bytes > 0: + measurements["composite"].append(event_payload_bytes) + + return base_updated, roi_updated + + +def update_sync_state( + source_frame: np.ndarray, + source_roi: tuple[int, int, int, int], + source_frame_index: int, + current_time: float, + profile: PreviewProfile, + state: SyncState, + measurements: dict[str, list[int]], +) -> bool: + """ + Атомарно обновляет обе части синхронного составного кадра. + """ + + if not should_update( + current_time, + state.next_composite_time, + ): + return False + + next_composite_id = state.composite_frame_id + 1 + + # Метаданные обеих частей назначаются до кодирования из одного + # и того же исходного кадра. + base_source_frame_index = source_frame_index + roi_source_frame_index = source_frame_index + base_timestamp = current_time + roi_timestamp = current_time + base_composite_id = next_composite_id + roi_composite_id = next_composite_id + + base_size, decoded_base = encode_base( + source_frame, + profile, + ) + roi_size, decoded_roi = encode_roi( + source_frame, + source_roi, + profile, + ) + + if base_timestamp != roi_timestamp: + state.timestamp_mismatch_count += 1 + + if ( + base_source_frame_index != roi_source_frame_index + or base_composite_id != roi_composite_id + ): + state.frame_id_mismatch_count += 1 + + # Отображаемое состояние заменяется только после готовности + # обоих декодированных JPEG. + state.latest_base = decoded_base + state.latest_roi = decoded_roi + state.composite_frame_id = next_composite_id + state.source_frame_index = source_frame_index + state.timestamp = current_time + state.last_composite_update_time = current_time + state.next_composite_time += SYNCHRONOUS_PERIOD_SECONDS + state.base_update_count += 1 + state.roi_update_count += 1 + state.synchronized_update_count += 1 + + measurements["base"].append(base_size) + measurements["roi"].append(roi_size) + measurements["composite"].append(base_size + roi_size) + + return True + + +def process_first_pass( + source_path: Path, + profiles: list[PreviewProfile], + source_width: int, + source_height: int, + source_fps: float, + expected_frame_count: int, +) -> tuple[Measurements, list[ProfileState]]: + """ + Измеряет размеры JPEG без создания preview-видео. + """ + + measurements = create_measurements(profiles) + states = create_states(profiles) + source_roi = normalized_roi_to_pixels( + source_width, + source_height, + ) + capture = cv2.VideoCapture(str(source_path)) + + if not capture.isOpened(): + raise RuntimeError( + f"OpenCV не смог открыть видео: {source_path}" + ) + + 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 + + for profile, state in zip(profiles, states): + profile_measurements = measurements[ + profile.profile_name + ] + + if isinstance(state, AsyncState): + update_async_state( + source_frame, + source_roi, + frame_index, + current_time, + profile, + state, + profile_measurements, + ) + else: + update_sync_state( + source_frame, + source_roi, + frame_index, + current_time, + profile, + state, + profile_measurements, + ) + + frame_index += 1 + + if ( + frame_index % 100 == 0 + or frame_index == expected_frame_count + ): + print( + f" First pass frames: " + f"{frame_index}/{expected_frame_count}" + ) + finally: + capture.release() + + if frame_index != expected_frame_count: + raise RuntimeError( + "Первый проход прочитал некорректное число кадров." + ) + + return measurements, states + + +def calculate_statistics( + profiles: list[PreviewProfile], + measurements: Measurements, + states: list[ProfileState], + source_duration_seconds: float, +) -> list[ProfileStatistics]: + """ + Рассчитывает итоговые фактические битрейты и размеры обновлений. + """ + + statistics: list[ProfileStatistics] = [] + + for profile, state in zip(profiles, states): + profile_measurements = measurements[ + profile.profile_name + ] + base_sizes = profile_measurements["base"] + roi_sizes = profile_measurements["roi"] + composite_sizes = profile_measurements["composite"] + + if not base_sizes or not roi_sizes or not composite_sizes: + raise RuntimeError( + f"Пустые измерения: {profile.profile_name}" + ) + + total_base_bytes = int(sum(base_sizes)) + total_roi_bytes = int(sum(roi_sizes)) + total_payload_bytes = ( + total_base_bytes + total_roi_bytes + ) + base_bitrate_kbps = ( + total_base_bytes + * 8.0 + / source_duration_seconds + / 1000.0 + ) + roi_bitrate_kbps = ( + total_roi_bytes + * 8.0 + / source_duration_seconds + / 1000.0 + ) + total_payload_bitrate_kbps = ( + total_payload_bytes + * 8.0 + / source_duration_seconds + / 1000.0 + ) + + if not np.isclose( + total_payload_bitrate_kbps, + base_bitrate_kbps + roi_bitrate_kbps, + rtol=0.0, + atol=1e-12, + ): + raise RuntimeError( + "Суммарный битрейт не равен BASE + ROI." + ) + + if isinstance(state, AsyncState): + synchronized_frames = None + timestamp_mismatch_count = None + frame_id_mismatch_count = None + else: + synchronized_frames = ( + state.synchronized_update_count + ) + timestamp_mismatch_count = ( + state.timestamp_mismatch_count + ) + frame_id_mismatch_count = ( + state.frame_id_mismatch_count + ) + + if ( + len(base_sizes) != len(roi_sizes) + or len(base_sizes) != synchronized_frames + ): + raise RuntimeError( + "Число синхронных обновлений не совпало." + ) + + if ( + timestamp_mismatch_count != 0 + or frame_id_mismatch_count != 0 + ): + raise RuntimeError( + "Обнаружено рассогласование sync-профиля." + ) + + composite_array = np.asarray( + composite_sizes, + dtype=np.float64, + ) + + statistics.append( + ProfileStatistics( + profile_name=profile.profile_name, + update_mode=profile.update_mode, + base_width=profile.base_width, + base_height=profile.base_height, + base_fps=profile.base_fps, + base_quality=profile.base_quality, + roi_width=profile.roi_width, + roi_height=profile.roi_height, + roi_fps=profile.roi_fps, + roi_quality=profile.roi_quality, + source_duration_s=source_duration_seconds, + selected_base_frames=len(base_sizes), + selected_roi_frames=len(roi_sizes), + synchronized_composite_frames=( + synchronized_frames + ), + total_base_bytes=total_base_bytes, + total_roi_bytes=total_roi_bytes, + total_payload_bytes=total_payload_bytes, + mean_base_frame_bytes=float( + np.mean( + np.asarray( + base_sizes, + dtype=np.float64, + ) + ) + ), + mean_roi_frame_bytes=float( + np.mean( + np.asarray( + roi_sizes, + dtype=np.float64, + ) + ) + ), + mean_composite_frame_bytes=float( + np.mean(composite_array) + ), + p95_composite_frame_bytes=float( + np.percentile(composite_array, 95) + ), + max_composite_frame_bytes=int( + np.max(composite_array) + ), + base_bitrate_kbps=base_bitrate_kbps, + roi_bitrate_kbps=roi_bitrate_kbps, + total_payload_bitrate_kbps=( + total_payload_bitrate_kbps + ), + timestamp_mismatch_count=( + timestamp_mismatch_count + ), + frame_id_mismatch_count=( + frame_id_mismatch_count + ), + ) + ) + + if len(statistics) != 4: + raise RuntimeError( + "Создано не четыре результата Lab027C." + ) + + return statistics + + +def reconstruct_panel( + latest_base: np.ndarray | None, + latest_roi: np.ndarray | None, + panel_roi: tuple[int, int, int, int], +) -> np.ndarray: + """ + Восстанавливает составную grayscale-панель размером 640x360. + """ + + if latest_base is None or latest_roi is None: + raise RuntimeError( + "BASE или ROI ещё не инициализированы." + ) + + reconstructed = cv2.resize( + latest_base, + (PANEL_WIDTH, PANEL_HEIGHT), + interpolation=cv2.INTER_LINEAR, + ) + x_min, y_min, x_max, y_max = panel_roi + resized_roi = cv2.resize( + 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, + color: tuple[int, int, int] = (255, 255, 255), +) -> None: + """ + Рисует одну ASCII-строку служебной информации. + """ + + cv2.putText( + image, + text, + (9, y_position), + cv2.FONT_HERSHEY_SIMPLEX, + 0.41, + color, + 1, + cv2.LINE_AA, + ) + + +def prepare_panel_background( + reconstructed_gray: np.ndarray, + panel_roi: tuple[int, int, int, int], +) -> np.ndarray: + """ + Преобразует панель в BGR, рисует ROI и фон подписей. + """ + + panel = cv2.cvtColor( + reconstructed_gray, + cv2.COLOR_GRAY2BGR, + ) + 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, 158), + (0, 0, 0), + thickness=-1, + ) + cv2.addWeighted( + overlay, + 0.74, + panel, + 0.26, + 0.0, + panel, + ) + + return panel + + +def draw_async_information( + reconstructed_gray: np.ndarray, + profile: PreviewProfile, + statistics: ProfileStatistics, + state: AsyncState, + current_time: float, + panel_roi: tuple[int, int, int, int], +) -> np.ndarray: + """ + Добавляет отдельные ID, source frame и возраст BASE/ROI. + """ + + panel = prepare_panel_background( + reconstructed_gray, + panel_roi, + ) + base_age = max( + 0.0, + current_time - state.last_base_update_time, + ) + roi_age = max( + 0.0, + current_time - state.last_roi_update_time, + ) + + draw_text_line( + panel, + "ASYNCHRONOUS | async_reference", + 18, + ) + draw_text_line( + panel, + ( + f"BASE {profile.base_width}x{profile.base_height} " + f"{profile.base_fps:g}fps Q{profile.base_quality}" + ), + 37, + ) + draw_text_line( + panel, + ( + f"ROI {profile.roi_width}x{profile.roi_height} " + f"{profile.roi_fps:g}fps Q{profile.roi_quality}" + ), + 56, + ) + draw_text_line( + panel, + ( + f"payload={statistics.total_payload_bitrate_kbps:.3f} " + f"kbps | video t={current_time:.3f}s" + ), + 75, + ) + draw_text_line( + panel, + ( + f"BASE ID={state.base_frame_id} " + f"src={state.base_source_frame_index} " + f"age={base_age:.3f}s" + ), + 94, + ) + draw_text_line( + panel, + ( + f"ROI ID={state.roi_frame_id} " + f"src={state.roi_source_frame_index} " + f"age={roi_age:.3f}s" + ), + 113, + ) + + update_labels: list[str] = [] + + if base_age <= NEW_LABEL_DURATION_SECONDS: + update_labels.append("NEW BASE") + + if roi_age <= NEW_LABEL_DURATION_SECONDS: + update_labels.append("NEW ROI") + + if update_labels: + draw_text_line( + panel, + " | ".join(update_labels), + 137, + color=(0, 255, 0), + ) + + return panel + + +def draw_sync_information( + reconstructed_gray: np.ndarray, + profile: PreviewProfile, + statistics: ProfileStatistics, + state: SyncState, + current_time: float, + panel_roi: tuple[int, int, int, int], +) -> np.ndarray: + """ + Добавляет единые ID, source frame, timestamp и возраст composite. + """ + + panel = prepare_panel_background( + reconstructed_gray, + panel_roi, + ) + composite_age = max( + 0.0, + current_time - state.last_composite_update_time, + ) + + draw_text_line( + panel, + f"SYNCHRONOUS | {profile.profile_name}", + 18, + ) + draw_text_line( + panel, + ( + f"BASE {profile.base_width}x{profile.base_height} " + f"2fps Q{profile.base_quality}" + ), + 37, + ) + draw_text_line( + panel, + ( + f"ROI {profile.roi_width}x{profile.roi_height} " + f"2fps Q{profile.roi_quality}" + ), + 56, + ) + draw_text_line( + panel, + ( + f"payload={statistics.total_payload_bitrate_kbps:.3f} " + f"kbps | video t={current_time:.3f}s" + ), + 75, + ) + draw_text_line( + panel, + ( + f"COMPOSITE ID={state.composite_frame_id} " + f"source frame={state.source_frame_index}" + ), + 94, + ) + draw_text_line( + panel, + ( + f"timestamp={state.timestamp:.3f}s " + f"age={composite_age:.3f}s" + ), + 113, + ) + + if composite_age <= NEW_LABEL_DURATION_SECONDS: + draw_text_line( + panel, + "NEW COMPOSITE", + 137, + 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}" + ) + + grid = np.vstack( + ( + np.hstack((panels[0], panels[1])), + np.hstack((panels[2], panels[3])), + ) + ) + + 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/mp4v 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 write_preview( + source_path: Path, + profiles: list[PreviewProfile], + statistics: list[ProfileStatistics], + source_width: int, + source_height: int, + source_fps: float, + expected_frame_count: int, +) -> tuple[ + Path, + bool, + int, + Measurements, + list[ProfileState], +]: + """ + Выполняет второй проход и записывает preview с итоговым bitrate. + """ + + PREVIEW_DIRECTORY.mkdir( + parents=True, + exist_ok=True, + ) + statistics_by_name = { + item.profile_name: item + for item in statistics + } + measurements = create_measurements(profiles) + states = create_states(profiles) + source_roi = normalized_roi_to_pixels( + source_width, + source_height, + ) + panel_roi = normalized_roi_to_pixels( + PANEL_WIDTH, + PANEL_HEIGHT, + ) + capture = cv2.VideoCapture(str(source_path)) + + if not capture.isOpened(): + raise RuntimeError( + f"OpenCV не смог открыть видео: {source_path}" + ) + + writer, preview_path, fallback_used = open_preview_writer() + 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}." + ) + + current_time = frame_index / source_fps + panels: list[np.ndarray] = [] + + for profile, state in zip(profiles, states): + profile_measurements = measurements[ + profile.profile_name + ] + profile_statistics = statistics_by_name[ + profile.profile_name + ] + + if isinstance(state, AsyncState): + update_async_state( + source_frame, + source_roi, + frame_index, + current_time, + profile, + state, + profile_measurements, + ) + reconstructed = reconstruct_panel( + state.latest_base, + state.latest_roi, + panel_roi, + ) + panels.append( + draw_async_information( + reconstructed, + profile, + profile_statistics, + state, + current_time, + panel_roi, + ) + ) + else: + update_sync_state( + source_frame, + source_roi, + frame_index, + current_time, + profile, + state, + profile_measurements, + ) + reconstructed = reconstruct_panel( + state.latest_base, + state.latest_roi, + panel_roi, + ) + panels.append( + draw_sync_information( + reconstructed, + profile, + profile_statistics, + 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" Second pass 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( + "Второй проход записал некорректное число кадров." + ) + + return ( + preview_path, + fallback_used, + frame_index, + measurements, + states, + ) + + +def compare_passes( + profiles: list[PreviewProfile], + first_measurements: Measurements, + second_measurements: Measurements, + first_states: list[ProfileState], + second_states: list[ProfileState], +) -> None: + """ + Проверяет точное совпадение размеров JPEG и числа обновлений. + """ + + for profile, first_state, second_state in zip( + profiles, + first_states, + second_states, + ): + profile_name = profile.profile_name + + for stream_name in ["base", "roi", "composite"]: + if ( + first_measurements[profile_name][stream_name] + != second_measurements[profile_name][stream_name] + ): + raise RuntimeError( + "Размеры JPEG двух проходов не совпали: " + f"{profile_name}, {stream_name}." + ) + + if isinstance(first_state, AsyncState): + if not isinstance(second_state, AsyncState): + raise RuntimeError( + "Тип состояния async изменился между проходами." + ) + + if ( + first_state.base_update_count + != second_state.base_update_count + or first_state.roi_update_count + != second_state.roi_update_count + ): + raise RuntimeError( + "Число async-обновлений между проходами различно." + ) + else: + if not isinstance(second_state, SyncState): + raise RuntimeError( + "Тип состояния sync изменился между проходами." + ) + + if ( + first_state.synchronized_update_count + != second_state.synchronized_update_count + or first_state.timestamp_mismatch_count + != second_state.timestamp_mismatch_count + or first_state.frame_id_mismatch_count + != second_state.frame_id_mismatch_count + ): + raise RuntimeError( + "Sync-проверка двух проходов не совпала." + ) + + +def optional_integer_text(value: int | None) -> str: + """ + Представляет целое значение либо N/A для CSV и отчёта. + """ + + return NOT_APPLICABLE if value is None else str(value) + + +def save_csv(statistics: list[ProfileStatistics]) -> None: + """ + Сохраняет четыре полные строки измерений в UTF-8 CSV. + """ + + with CSV_PATH.open( + "w", + encoding="utf-8", + newline="", + ) as csv_file: + writer = csv.DictWriter( + csv_file, + fieldnames=CSV_FIELD_NAMES, + ) + writer.writeheader() + + for item in statistics: + writer.writerow( + { + "profile_name": item.profile_name, + "update_mode": item.update_mode, + "base_width": item.base_width, + "base_height": item.base_height, + "base_fps": f"{item.base_fps:.6f}", + "base_quality": item.base_quality, + "roi_width": item.roi_width, + "roi_height": item.roi_height, + "roi_fps": f"{item.roi_fps:.6f}", + "roi_quality": item.roi_quality, + "source_duration_s": ( + f"{item.source_duration_s:.6f}" + ), + "selected_base_frames": ( + item.selected_base_frames + ), + "selected_roi_frames": ( + item.selected_roi_frames + ), + "synchronized_composite_frames": ( + optional_integer_text( + item.synchronized_composite_frames + ) + ), + "total_base_bytes": item.total_base_bytes, + "total_roi_bytes": item.total_roi_bytes, + "total_payload_bytes": ( + item.total_payload_bytes + ), + "mean_base_frame_bytes": ( + f"{item.mean_base_frame_bytes:.3f}" + ), + "mean_roi_frame_bytes": ( + f"{item.mean_roi_frame_bytes:.3f}" + ), + "mean_composite_frame_bytes": ( + f"{item.mean_composite_frame_bytes:.3f}" + ), + "p95_composite_frame_bytes": ( + f"{item.p95_composite_frame_bytes:.3f}" + ), + "max_composite_frame_bytes": ( + item.max_composite_frame_bytes + ), + "base_bitrate_kbps": ( + f"{item.base_bitrate_kbps:.6f}" + ), + "roi_bitrate_kbps": ( + f"{item.roi_bitrate_kbps:.6f}" + ), + "total_payload_bitrate_kbps": ( + f"{item.total_payload_bitrate_kbps:.6f}" + ), + "timestamp_mismatch_count": ( + optional_integer_text( + item.timestamp_mismatch_count + ) + ), + "frame_id_mismatch_count": ( + optional_integer_text( + item.frame_id_mismatch_count + ) + ), + } + ) + + +def format_statistics_line(item: ProfileStatistics) -> str: + """ + Формирует полную строку профиля для текстового отчёта. + """ + + return ( + f"{item.profile_name}: mode={item.update_mode}, " + f"BASE={item.base_width}x{item.base_height}, " + f"{item.base_fps:.3f} fps, Q{item.base_quality}, " + f"base_frames={item.selected_base_frames}, " + f"base_bytes={item.total_base_bytes}, " + f"base_bitrate={item.base_bitrate_kbps:.6f} kbit/s; " + f"ROI={item.roi_width}x{item.roi_height}, " + f"{item.roi_fps:.3f} fps, Q{item.roi_quality}, " + f"roi_frames={item.selected_roi_frames}, " + f"roi_bytes={item.total_roi_bytes}, " + f"roi_bitrate={item.roi_bitrate_kbps:.6f} kbit/s; " + f"total_bytes={item.total_payload_bytes}, " + f"total_bitrate=" + f"{item.total_payload_bitrate_kbps:.6f} kbit/s, " + f"sync_frames=" + f"{optional_integer_text(item.synchronized_composite_frames)}, " + f"timestamp_mismatch=" + f"{optional_integer_text(item.timestamp_mismatch_count)}, " + f"frame_id_mismatch=" + f"{optional_integer_text(item.frame_id_mismatch_count)}" + ) + + +def write_report( + statistics: list[ProfileStatistics], + source_width: int, + source_height: int, + source_fps: float, + source_frame_count: int, + source_duration_seconds: float, + preview_path: Path, + fallback_used: bool, +) -> None: + """ + Сохраняет краткий UTF-8 отчёт без автоматического выбора профиля. + """ + + source_roi = normalized_roi_to_pixels( + source_width, + source_height, + ) + lines = [ + "Lab027C. Динамическое сравнение асинхронного " + "и синхронного BASE + ROI", + "", + "Цель: визуально сравнить ранее выбранный асинхронный " + "профиль BASE 1 fps + ROI 2 fps с тремя синхронными " + "профилями BASE 2 fps + ROI 2 fps.", + "", + f"Исходное видео: {SOURCE_VIDEO_PATH}", + f"Исходное разрешение: {source_width}x{source_height}", + f"Исходный FPS: {source_fps:.6f}", + f"Число кадров: {source_frame_count}", + f"Длительность: {source_duration_seconds:.6f} с", + "ROI: " + f"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]}.", + "", + "Результат ручной оценки предыдущей 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 " + "и атомарно заменяют отображаемый составной кадр.", + "", + "Фактические результаты четырёх профилей:", + ] + lines.extend( + format_statistics_line(item) + for item in statistics + ) + lines.extend( + [ + "", + "Количество обновлений:", + ] + ) + + for item in statistics: + lines.append( + f"{item.profile_name}: " + f"BASE={item.selected_base_frames}, " + f"ROI={item.selected_roi_frames}, " + "synchronized=" + f"{optional_integer_text(item.synchronized_composite_frames)}." + ) + + lines.extend( + [ + "", + "Mismatch-проверка:", + ] + ) + + for item in statistics: + lines.append( + f"{item.profile_name}: timestamp=" + f"{optional_integer_text(item.timestamp_mismatch_count)}, " + "frame_id=" + f"{optional_integer_text(item.frame_id_mismatch_count)}." + ) + + lines.extend( + [ + "", + f"Preview: {preview_path}", + "Формат: " + + ("AVI/MJPG fallback." if fallback_used else "MP4/mp4v."), + "Радиопротокол, CRC, FEC, фрагментация и служебный " + "трафик пока не учитываются.", + "Программа не выбирает лучший профиль и не объявляет " + "режим безопасным автоматически.", + "Следующий шаг: ручной выбор пользователем после " + "просмотра preview-видео.", + "", + ] + ) + + REPORT_PATH.write_text( + "\n".join(lines), + encoding="utf-8", + ) + + +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], +]: + """ + Проверяет preview и читает первый, средний и последний кадры. + """ + + if not preview_path.exists() or 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, height) != (OUTPUT_WIDTH, OUTPUT_HEIGHT): + raise RuntimeError( + f"Некорректное разрешение preview: {width}x{height}." + ) + + if frame_count != source_frame_count: + raise RuntimeError( + "Число кадров preview не совпало с исходным." + ) + + if ( + abs(duration_seconds - source_duration_seconds) + > 1.0 / fps + FRAME_TIME_EPSILON_SECONDS + ): + raise RuntimeError( + "Длительность preview отличается более чем на один кадр." + ) + + if not all( + frame_result[0] + for frame_result in [ + first_frame, + middle_frame, + last_frame, + ] + ): + raise RuntimeError( + "Не удалось прочитать один из контрольных кадров." + ) + + return ( + width, + height, + fps, + frame_count, + duration_seconds, + first_frame, + middle_frame, + last_frame, + ) + + +def validate_sync_statistics( + statistics: list[ProfileStatistics], +) -> None: + """ + Проверяет N/A async и нулевые mismatch всех sync-профилей. + """ + + for item in statistics: + if item.update_mode == UPDATE_MODE_ASYNCHRONOUS: + if ( + item.synchronized_composite_frames is not None + or item.timestamp_mismatch_count is not None + or item.frame_id_mismatch_count is not None + ): + raise RuntimeError( + "Async mismatch должен быть N/A." + ) + else: + if ( + item.selected_base_frames + != item.selected_roi_frames + or item.selected_base_frames + != item.synchronized_composite_frames + or item.timestamp_mismatch_count != 0 + or item.frame_id_mismatch_count != 0 + ): + raise RuntimeError( + f"Некорректный sync: {item.profile_name}" + ) + + +def main() -> None: + """ + Выполняет оба прохода, сохраняет CSV/TXT и проверяет preview. + """ + + print("Reading source video metadata...") + ( + source_width, + source_height, + source_fps, + source_frame_count, + source_duration_seconds, + source_file_size_bytes, + ) = read_video_metadata(SOURCE_VIDEO_PATH) + + print(f" Source: {SOURCE_VIDEO_PATH}") + print(f" File size: {source_file_size_bytes} bytes") + 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() + OUTPUT_DIRECTORY.mkdir( + parents=True, + exist_ok=True, + ) + + print("First pass: measuring JPEG payload...") + first_measurements, first_states = process_first_pass( + 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, + ) + statistics = calculate_statistics( + profiles, + first_measurements, + first_states, + source_duration_seconds, + ) + validate_sync_statistics(statistics) + + print("Second pass: writing preview...") + ( + preview_path, + fallback_used, + written_frame_count, + second_measurements, + second_states, + ) = write_preview( + source_path=SOURCE_VIDEO_PATH, + profiles=profiles, + statistics=statistics, + source_width=source_width, + source_height=source_height, + source_fps=source_fps, + expected_frame_count=source_frame_count, + ) + compare_passes( + profiles, + first_measurements, + second_measurements, + first_states, + second_states, + ) + + print("Saving CSV and report...") + save_csv(statistics) + write_report( + statistics=statistics, + source_width=source_width, + source_height=source_height, + source_fps=source_fps, + source_frame_count=source_frame_count, + source_duration_seconds=source_duration_seconds, + preview_path=preview_path, + fallback_used=fallback_used, + ) + + ( + preview_width, + preview_height, + preview_fps, + preview_frame_count, + preview_duration, + first_frame, + middle_frame, + last_frame, + ) = verify_preview( + preview_path, + source_frame_count, + source_duration_seconds, + ) + + if not CSV_PATH.exists() or CSV_PATH.stat().st_size <= 0: + raise RuntimeError("CSV не создан или пуст.") + + if not REPORT_PATH.exists() or REPORT_PATH.stat().st_size <= 0: + raise RuntimeError("Отчёт не создан или пуст.") + + print("") + print("Measured profiles:") + for item in statistics: + print( + f" {item.profile_name}: " + f"BASE={item.base_bitrate_kbps:.6f}, " + f"ROI={item.roi_bitrate_kbps:.6f}, " + f"total={item.total_payload_bitrate_kbps:.6f} " + "kbit/s, " + "sync=" + f"{optional_integer_text(item.synchronized_composite_frames)}, " + "timestamp mismatch=" + f"{optional_integer_text(item.timestamp_mismatch_count)}, " + "frame ID mismatch=" + f"{optional_integer_text(item.frame_id_mismatch_count)}" + ) + + print("") + print("Two-pass JPEG sizes: identical") + print(f"Preview: {preview_path}") + print(f"Fallback used: {fallback_used}") + print(f"Preview size: {preview_path.stat().st_size} bytes") + print( + f"Preview resolution: {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: {preview_duration:.6f} s") + print(f"First frame: {first_frame}") + print(f"Middle frame: {middle_frame}") + print(f"Last frame: {last_frame}") + print("") + print("Lab027C completed successfully.") + + +if __name__ == "__main__": + main() diff --git a/tests/lab027d_fps_quality_preview.py b/tests/lab027d_fps_quality_preview.py new file mode 100644 index 0000000..1abb11d --- /dev/null +++ b/tests/lab027d_fps_quality_preview.py @@ -0,0 +1,1517 @@ +""" +Lab027D. Сравнение синхронного BASE + ROI при 2 и 3 fps. + +Лабораторная формирует четыре синхронных профиля с различными +частотой обновления и JPEG Quality. Все JPEG существуют только +в памяти. Первый проход измеряет фактический payload, второй +проход повторяет то же расписание и записывает сравнительное +preview-видео 2x2. + +Лучший профиль программой не выбирается: итоговый выбор должен +сделать пользователь после просмотра динамического сравнения. +""" + +from __future__ import annotations + +import csv +from dataclasses import dataclass +from pathlib import Path + +import cv2 +import numpy as np + + +SOURCE_VIDEO_PATH = Path("data/raw/lab026_rover_source.mp4") + +OUTPUT_DIRECTORY = Path("data/processed/lab027d") +CSV_PATH = OUTPUT_DIRECTORY / "lab027d_profiles.csv" +REPORT_PATH = OUTPUT_DIRECTORY / "lab027d_report.txt" + +PREVIEW_DIRECTORY = Path("data/raw/lab027d_previews") +MP4_PREVIEW_PATH = ( + PREVIEW_DIRECTORY / "lab027d_fps_quality_preview.mp4" +) +AVI_PREVIEW_PATH = ( + PREVIEW_DIRECTORY / "lab027d_fps_quality_preview.avi" +) + +ROI_X_MIN = 0.20 +ROI_X_MAX = 0.80 +ROI_Y_MIN = 0.42 +ROI_Y_MAX = 1.00 + +PANEL_WIDTH = 640 +PANEL_HEIGHT = 360 +OUTPUT_WIDTH = PANEL_WIDTH * 2 +OUTPUT_HEIGHT = PANEL_HEIGHT * 2 +OUTPUT_FPS = 30.0 + +SERVICE_OVERHEAD_FACTOR = 1.10 +FEC_RATE_TWO_THIRDS = 2.0 / 3.0 +FEC_RATE_ONE_HALF = 0.5 + +FRAME_TIME_EPSILON_SECONDS = 1e-9 +NEW_LABEL_DURATION_SECONDS = 0.15 + +CSV_FIELD_NAMES = [ + "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", +] + + +@dataclass(frozen=True) +class PreviewProfile: + """ + Описывает один синхронный профиль BASE + ROI. + """ + + profile_name: str + fps: float + base_width: int + base_height: int + base_quality: int + roi_width: int + roi_height: int + roi_quality: int + + @property + def period_seconds(self) -> float: + """ + Возвращает период атомарного обновления профиля. + """ + + return 1.0 / self.fps + + +@dataclass +class SyncState: + """ + Хранит последнее атомарно опубликованное состояние профиля. + """ + + next_composite_time: float = 0.0 + last_composite_update_time: float = float("-inf") + composite_frame_id: int = -1 + source_frame_index: int = -1 + timestamp: float = 0.0 + latest_base: np.ndarray | None = None + latest_roi: np.ndarray | None = None + selected_base_frames: int = 0 + selected_roi_frames: int = 0 + synchronized_composite_frames: int = 0 + timestamp_mismatch_count: int = 0 + frame_id_mismatch_count: int = 0 + + +@dataclass(frozen=True) +class ProfileStatistics: + """ + Содержит все измерения одного профиля для CSV и отчёта. + """ + + profile_name: str + base_width: int + base_height: int + fps: float + base_quality: int + roi_width: int + roi_height: int + roi_quality: int + source_duration_s: float + selected_base_frames: int + selected_roi_frames: int + synchronized_composite_frames: int + total_base_bytes: int + total_roi_bytes: int + total_payload_bytes: int + mean_base_frame_bytes: float + mean_roi_frame_bytes: float + mean_composite_frame_bytes: float + p95_composite_frame_bytes: float + max_composite_frame_bytes: int + base_bitrate_kbps: float + roi_bitrate_kbps: float + total_payload_bitrate_kbps: float + channel_rate_fec_2_3_kbps: float + channel_rate_fec_1_2_kbps: float + timestamp_mismatch_count: int + frame_id_mismatch_count: int + + +Measurements = dict[str, dict[str, list[int]]] + + +def read_video_metadata( + source_path: Path, +) -> tuple[int, int, float, int, float, int]: + """ + Читает метаданные исходного видео без изменения файла. + """ + + if not source_path.exists(): + raise FileNotFoundError( + 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("Некорректное разрешение исходного видео.") + + if fps <= 0.0 or frame_count <= 0: + raise RuntimeError( + "Некорректные FPS или число кадров исходного видео." + ) + + duration_seconds = frame_count / fps + file_size_bytes = source_path.stat().st_size + + if duration_seconds <= 0.0 or file_size_bytes <= 0: + raise RuntimeError( + "Некорректная длительность или размер исходного видео." + ) + + return ( + width, + height, + fps, + frame_count, + duration_seconds, + file_size_bytes, + ) + + +def build_profiles() -> list[PreviewProfile]: + """ + Создаёт ровно четыре профиля, заданных для Lab027D. + """ + + profiles = [ + PreviewProfile( + profile_name="sync_2fps_base_q20_roi_q30", + fps=2.0, + base_width=240, + base_height=135, + base_quality=20, + roi_width=320, + roi_height=180, + roi_quality=30, + ), + PreviewProfile( + profile_name="sync_3fps_base_q20_roi_q30", + fps=3.0, + base_width=240, + base_height=135, + base_quality=20, + roi_width=320, + roi_height=180, + roi_quality=30, + ), + PreviewProfile( + profile_name="sync_3fps_base_q23_roi_q33", + fps=3.0, + base_width=240, + base_height=135, + base_quality=23, + roi_width=320, + roi_height=180, + roi_quality=33, + ), + PreviewProfile( + profile_name="sync_3fps_base_q25_roi_q35", + fps=3.0, + base_width=240, + base_height=135, + base_quality=25, + roi_width=320, + roi_height=180, + roi_quality=35, + ), + ] + + if len(profiles) != 4: + raise RuntimeError("Lab027D должна содержать четыре профиля.") + + if len({profile.profile_name for profile in profiles}) != 4: + raise RuntimeError("Имена профилей Lab027D не уникальны.") + + return profiles + + +def normalized_roi_to_pixels( + width: int, + height: int, +) -> tuple[int, int, int, int]: + """ + Переводит нормализованные координаты ROI в пиксели. + """ + + 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)) + + if not ( + 0 <= x_min < x_max <= width + and 0 <= y_min < y_max <= height + ): + raise RuntimeError("Расчётная ROI выходит за границы кадра.") + + return x_min, y_min, x_max, y_max + + +def should_update( + current_time: float, + next_update_time: float, +) -> bool: + """ + Проверяет наступление времени следующего обновления. + """ + + return ( + current_time + FRAME_TIME_EPSILON_SECONDS + >= next_update_time + ) + + +def encode_decode_jpeg( + grayscale_image: np.ndarray, + quality: int, +) -> tuple[int, np.ndarray]: + """ + Кодирует grayscale-кадр в JPEG в памяти и сразу декодирует. + """ + + encode_parameters = [ + int(cv2.IMWRITE_JPEG_QUALITY), + int(quality), + ] + encoded, jpeg_buffer = cv2.imencode( + ".jpg", + grayscale_image, + encode_parameters, + ) + + if not encoded: + raise RuntimeError("OpenCV не смог закодировать JPEG.") + + decoded = cv2.imdecode( + jpeg_buffer, + cv2.IMREAD_GRAYSCALE, + ) + + if decoded is None: + raise RuntimeError("OpenCV не смог декодировать JPEG.") + + return int(jpeg_buffer.nbytes), decoded + + +def encode_base( + source_frame: np.ndarray, + profile: PreviewProfile, +) -> tuple[int, np.ndarray]: + """ + Формирует, кодирует и декодирует BASE одного профиля. + """ + + grayscale = cv2.cvtColor( + source_frame, + cv2.COLOR_BGR2GRAY, + ) + resized = cv2.resize( + grayscale, + (profile.base_width, profile.base_height), + interpolation=cv2.INTER_AREA, + ) + + return encode_decode_jpeg(resized, profile.base_quality) + + +def encode_roi( + source_frame: np.ndarray, + source_roi: tuple[int, int, int, int], + profile: PreviewProfile, +) -> tuple[int, np.ndarray]: + """ + Вырезает из исходного кадра ROI и обрабатывает JPEG в памяти. + """ + + x_min, y_min, x_max, y_max = source_roi + roi = source_frame[y_min:y_max, x_min:x_max] + + if roi.size == 0: + raise RuntimeError("Получена пустая ROI.") + + grayscale = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) + resized = cv2.resize( + grayscale, + (profile.roi_width, profile.roi_height), + interpolation=cv2.INTER_AREA, + ) + + return encode_decode_jpeg(resized, profile.roi_quality) + + +def create_measurements( + profiles: list[PreviewProfile], +) -> Measurements: + """ + Создаёт пустые списки размеров JPEG для одного прохода. + """ + + return { + profile.profile_name: { + "base": [], + "roi": [], + "composite": [], + } + for profile in profiles + } + + +def create_states( + profiles: list[PreviewProfile], +) -> list[SyncState]: + """ + Создаёт независимое синхронное состояние каждого профиля. + """ + + return [SyncState() for _ in profiles] + + +def update_sync_state( + source_frame: np.ndarray, + source_roi: tuple[int, int, int, int], + source_frame_index: int, + current_time: float, + profile: PreviewProfile, + state: SyncState, + measurements: dict[str, list[int]], +) -> bool: + """ + Атомарно обновляет BASE и ROI из одного исходного кадра. + """ + + if not should_update(current_time, state.next_composite_time): + return False + + next_composite_frame_id = state.composite_frame_id + 1 + + # Метаданные обеих частей намеренно формируются отдельно, + # чтобы проверка синхронизации была явной. + base_source_frame_index = source_frame_index + roi_source_frame_index = source_frame_index + base_timestamp = current_time + roi_timestamp = current_time + base_composite_frame_id = next_composite_frame_id + roi_composite_frame_id = next_composite_frame_id + + base_size, decoded_base = encode_base(source_frame, profile) + roi_size, decoded_roi = encode_roi( + source_frame, + source_roi, + profile, + ) + + if base_timestamp != roi_timestamp: + state.timestamp_mismatch_count += 1 + + if ( + base_source_frame_index != roi_source_frame_index + or base_composite_frame_id != roi_composite_frame_id + ): + state.frame_id_mismatch_count += 1 + + # Публикация выполняется только после готовности обеих частей. + state.latest_base = decoded_base + state.latest_roi = decoded_roi + state.composite_frame_id = next_composite_frame_id + state.source_frame_index = source_frame_index + state.timestamp = current_time + state.last_composite_update_time = current_time + state.next_composite_time += profile.period_seconds + state.selected_base_frames += 1 + state.selected_roi_frames += 1 + state.synchronized_composite_frames += 1 + + measurements["base"].append(base_size) + measurements["roi"].append(roi_size) + measurements["composite"].append(base_size + roi_size) + + return True + + +def process_first_pass( + source_path: Path, + profiles: list[PreviewProfile], + source_width: int, + source_height: int, + source_fps: float, + expected_frame_count: int, +) -> tuple[Measurements, list[SyncState]]: + """ + Выполняет первый проход и измеряет JPEG payload без видео. + """ + + measurements = create_measurements(profiles) + states = create_states(profiles) + source_roi = normalized_roi_to_pixels( + source_width, + source_height, + ) + capture = cv2.VideoCapture(str(source_path)) + + if not capture.isOpened(): + raise RuntimeError( + f"OpenCV не смог открыть видео: {source_path}" + ) + + 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 + + for profile, state in zip(profiles, states): + update_sync_state( + source_frame, + source_roi, + frame_index, + current_time, + profile, + state, + measurements[profile.profile_name], + ) + + frame_index += 1 + + if ( + frame_index % 100 == 0 + or frame_index == expected_frame_count + ): + print( + " First pass frames: " + f"{frame_index}/{expected_frame_count}" + ) + finally: + capture.release() + + if frame_index != expected_frame_count: + raise RuntimeError( + "Первый проход прочитал некорректное число кадров." + ) + + return measurements, states + + +def calculate_statistics( + profiles: list[PreviewProfile], + measurements: Measurements, + states: list[SyncState], + source_duration_seconds: float, +) -> list[ProfileStatistics]: + """ + Рассчитывает payload и иллюстративные канальные скорости. + """ + + statistics: list[ProfileStatistics] = [] + + for profile, state in zip(profiles, states): + profile_measurements = measurements[ + profile.profile_name + ] + base_sizes = profile_measurements["base"] + roi_sizes = profile_measurements["roi"] + composite_sizes = profile_measurements["composite"] + + if not base_sizes or not roi_sizes or not composite_sizes: + raise RuntimeError( + f"Пустые измерения: {profile.profile_name}" + ) + + if not ( + len(base_sizes) + == len(roi_sizes) + == len(composite_sizes) + == state.synchronized_composite_frames + ): + raise RuntimeError( + f"Число обновлений не совпало: " + f"{profile.profile_name}" + ) + + total_base_bytes = int(sum(base_sizes)) + total_roi_bytes = int(sum(roi_sizes)) + total_payload_bytes = total_base_bytes + total_roi_bytes + + base_bitrate_kbps = ( + total_base_bytes + * 8.0 + / source_duration_seconds + / 1000.0 + ) + roi_bitrate_kbps = ( + total_roi_bytes + * 8.0 + / source_duration_seconds + / 1000.0 + ) + total_payload_bitrate_kbps = ( + total_payload_bytes + * 8.0 + / source_duration_seconds + / 1000.0 + ) + + if not np.isclose( + total_payload_bitrate_kbps, + base_bitrate_kbps + roi_bitrate_kbps, + rtol=0.0, + atol=1e-12, + ): + raise RuntimeError( + "Суммарный bitrate не равен BASE + ROI." + ) + + channel_rate_fec_2_3_kbps = ( + total_payload_bitrate_kbps + * SERVICE_OVERHEAD_FACTOR + / FEC_RATE_TWO_THIRDS + ) + channel_rate_fec_1_2_kbps = ( + total_payload_bitrate_kbps + * SERVICE_OVERHEAD_FACTOR + / FEC_RATE_ONE_HALF + ) + + statistics.append( + ProfileStatistics( + profile_name=profile.profile_name, + base_width=profile.base_width, + base_height=profile.base_height, + fps=profile.fps, + base_quality=profile.base_quality, + roi_width=profile.roi_width, + roi_height=profile.roi_height, + roi_quality=profile.roi_quality, + source_duration_s=source_duration_seconds, + selected_base_frames=state.selected_base_frames, + selected_roi_frames=state.selected_roi_frames, + synchronized_composite_frames=( + state.synchronized_composite_frames + ), + total_base_bytes=total_base_bytes, + total_roi_bytes=total_roi_bytes, + total_payload_bytes=total_payload_bytes, + mean_base_frame_bytes=float(np.mean(base_sizes)), + mean_roi_frame_bytes=float(np.mean(roi_sizes)), + mean_composite_frame_bytes=float( + np.mean(composite_sizes) + ), + p95_composite_frame_bytes=float( + np.percentile(composite_sizes, 95) + ), + max_composite_frame_bytes=int(max(composite_sizes)), + base_bitrate_kbps=base_bitrate_kbps, + roi_bitrate_kbps=roi_bitrate_kbps, + total_payload_bitrate_kbps=( + total_payload_bitrate_kbps + ), + channel_rate_fec_2_3_kbps=( + channel_rate_fec_2_3_kbps + ), + channel_rate_fec_1_2_kbps=( + channel_rate_fec_1_2_kbps + ), + timestamp_mismatch_count=( + state.timestamp_mismatch_count + ), + frame_id_mismatch_count=( + state.frame_id_mismatch_count + ), + ) + ) + + return statistics + + +def reconstruct_panel( + profile: PreviewProfile, + state: SyncState, +) -> np.ndarray: + """ + Реконструирует панель 640x360 из последнего BASE и ROI. + """ + + if state.latest_base is None or state.latest_roi is None: + return np.zeros( + (PANEL_HEIGHT, PANEL_WIDTH, 3), + dtype=np.uint8, + ) + + base_large = cv2.resize( + state.latest_base, + (PANEL_WIDTH, PANEL_HEIGHT), + interpolation=cv2.INTER_LINEAR, + ) + panel = cv2.cvtColor(base_large, cv2.COLOR_GRAY2BGR) + + panel_roi = normalized_roi_to_pixels( + PANEL_WIDTH, + PANEL_HEIGHT, + ) + x_min, y_min, x_max, y_max = panel_roi + roi_large = cv2.resize( + state.latest_roi, + (x_max - x_min, y_max - y_min), + interpolation=cv2.INTER_LINEAR, + ) + roi_bgr = cv2.cvtColor(roi_large, cv2.COLOR_GRAY2BGR) + panel[y_min:y_max, x_min:x_max] = roi_bgr + + cv2.rectangle( + panel, + (x_min, y_min), + (x_max - 1, y_max - 1), + (0, 255, 255), + 2, + ) + + return panel + + +def draw_text_line( + panel: np.ndarray, + text: str, + line_index: int, + color: tuple[int, int, int] = (255, 255, 255), +) -> None: + """ + Рисует одну строку читаемой подписи на панели. + """ + + y_position = 22 + line_index * 21 + cv2.putText( + panel, + text, + (9, y_position), + cv2.FONT_HERSHEY_SIMPLEX, + 0.48, + (0, 0, 0), + 3, + cv2.LINE_AA, + ) + cv2.putText( + panel, + text, + (9, y_position), + cv2.FONT_HERSHEY_SIMPLEX, + 0.48, + color, + 1, + cv2.LINE_AA, + ) + + +def draw_sync_information( + panel: np.ndarray, + profile: PreviewProfile, + state: SyncState, + statistics: ProfileStatistics, + current_time: float, +) -> None: + """ + Добавляет к панели параметры и динамическое состояние. + """ + + age_seconds = max( + 0.0, + current_time - state.last_composite_update_time, + ) + lines = [ + f"{profile.profile_name} | SYNCHRONOUS", + f"FPS {profile.fps:.0f} | " + f"BASE {profile.base_width}x{profile.base_height} " + f"Q{profile.base_quality}", + f"ROI {profile.roi_width}x{profile.roi_height} " + f"Q{profile.roi_quality}", + "payload " + f"{statistics.total_payload_bitrate_kbps:.3f} kbit/s", + "channel FEC 2/3 " + f"{statistics.channel_rate_fec_2_3_kbps:.3f} kbit/s", + f"time {current_time:.3f} s | " + f"composite ID {state.composite_frame_id}", + f"source frame {state.source_frame_index} | " + f"age {age_seconds:.3f} s", + ] + + for line_index, text in enumerate(lines): + draw_text_line(panel, text, line_index) + + if age_seconds <= NEW_LABEL_DURATION_SECONDS: + draw_text_line( + panel, + "NEW COMPOSITE", + len(lines), + color=(0, 255, 0), + ) + + +def compose_grid(panels: list[np.ndarray]) -> np.ndarray: + """ + Объединяет четыре панели в сетку 2x2 размером 1280x720. + """ + + if len(panels) != 4: + raise RuntimeError("Для preview требуется четыре панели.") + + 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/mp4v или разрешённый AVI/MJPG fallback. + """ + + PREVIEW_DIRECTORY.mkdir(parents=True, exist_ok=True) + + mp4_writer = cv2.VideoWriter( + str(MP4_PREVIEW_PATH), + cv2.VideoWriter_fourcc(*"mp4v"), + OUTPUT_FPS, + (OUTPUT_WIDTH, OUTPUT_HEIGHT), + ) + + 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), + ) + + if not avi_writer.isOpened(): + avi_writer.release() + raise RuntimeError( + "Не удалось открыть MP4/mp4v и AVI/MJPG writer." + ) + + return avi_writer, AVI_PREVIEW_PATH, True + + +def write_preview( + source_path: Path, + profiles: list[PreviewProfile], + statistics: list[ProfileStatistics], + source_width: int, + source_height: int, + source_fps: float, + expected_frame_count: int, +) -> tuple[ + Path, + bool, + int, + Measurements, + list[SyncState], +]: + """ + Выполняет второй проход и записывает сравнительное preview. + """ + + measurements = create_measurements(profiles) + states = create_states(profiles) + source_roi = normalized_roi_to_pixels( + source_width, + source_height, + ) + capture = cv2.VideoCapture(str(source_path)) + + if not capture.isOpened(): + raise RuntimeError( + f"OpenCV не смог открыть видео: {source_path}" + ) + + writer, preview_path, fallback_used = open_preview_writer() + 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}." + ) + + current_time = frame_index / source_fps + panels: list[np.ndarray] = [] + + for profile, state, profile_statistics in zip( + profiles, + states, + statistics, + ): + update_sync_state( + source_frame, + source_roi, + frame_index, + current_time, + profile, + state, + measurements[profile.profile_name], + ) + panel = reconstruct_panel(profile, state) + draw_sync_information( + panel, + profile, + state, + profile_statistics, + current_time, + ) + panels.append(panel) + + writer.write(compose_grid(panels)) + frame_index += 1 + + if ( + frame_index % 100 == 0 + or frame_index == expected_frame_count + ): + print( + " Second pass frames: " + f"{frame_index}/{expected_frame_count}" + ) + finally: + capture.release() + writer.release() + + if frame_index != expected_frame_count: + raise RuntimeError( + "Второй проход записал некорректное число кадров." + ) + + return ( + preview_path, + fallback_used, + frame_index, + measurements, + states, + ) + + +def compare_passes( + profiles: list[PreviewProfile], + first_measurements: Measurements, + second_measurements: Measurements, + first_states: list[SyncState], + second_states: list[SyncState], +) -> None: + """ + Проверяет полное совпадение измерений двух проходов. + """ + + for profile, first_state, second_state in zip( + profiles, + first_states, + second_states, + ): + profile_name = profile.profile_name + + for stream_name in ["base", "roi", "composite"]: + if ( + first_measurements[profile_name][stream_name] + != second_measurements[profile_name][stream_name] + ): + raise RuntimeError( + "Размеры JPEG двух проходов не совпали: " + f"{profile_name}, {stream_name}." + ) + + if ( + first_state.selected_base_frames + != second_state.selected_base_frames + or first_state.selected_roi_frames + != second_state.selected_roi_frames + or first_state.synchronized_composite_frames + != second_state.synchronized_composite_frames + or first_state.timestamp_mismatch_count + != second_state.timestamp_mismatch_count + or first_state.frame_id_mismatch_count + != second_state.frame_id_mismatch_count + ): + raise RuntimeError( + "Состояния двух проходов не совпали: " + f"{profile_name}." + ) + + +def validate_statistics( + statistics: list[ProfileStatistics], +) -> None: + """ + Проверяет синхронность и арифметику всех профилей. + """ + + if len(statistics) != 4: + raise RuntimeError("Ожидалось четыре строки статистики.") + + for item in statistics: + if not ( + item.selected_base_frames + == item.selected_roi_frames + == item.synchronized_composite_frames + ): + raise RuntimeError( + f"Обновления не синхронны: {item.profile_name}" + ) + + if ( + item.timestamp_mismatch_count != 0 + or item.frame_id_mismatch_count != 0 + ): + raise RuntimeError( + f"Обнаружен mismatch: {item.profile_name}" + ) + + if ( + item.total_payload_bytes + != item.total_base_bytes + item.total_roi_bytes + ): + raise RuntimeError( + f"Ошибка суммы payload: {item.profile_name}" + ) + + +def save_csv(statistics: list[ProfileStatistics]) -> None: + """ + Сохраняет четыре полные строки результатов в UTF-8 CSV. + """ + + with CSV_PATH.open( + "w", + encoding="utf-8", + newline="", + ) as csv_file: + writer = csv.DictWriter( + csv_file, + fieldnames=CSV_FIELD_NAMES, + ) + writer.writeheader() + + for item in statistics: + writer.writerow( + { + "profile_name": item.profile_name, + "base_width": item.base_width, + "base_height": item.base_height, + "fps": f"{item.fps:.6f}", + "base_quality": item.base_quality, + "roi_width": item.roi_width, + "roi_height": item.roi_height, + "roi_quality": item.roi_quality, + "source_duration_s": ( + f"{item.source_duration_s:.6f}" + ), + "selected_base_frames": ( + item.selected_base_frames + ), + "selected_roi_frames": ( + item.selected_roi_frames + ), + "synchronized_composite_frames": ( + item.synchronized_composite_frames + ), + "total_base_bytes": item.total_base_bytes, + "total_roi_bytes": item.total_roi_bytes, + "total_payload_bytes": ( + item.total_payload_bytes + ), + "mean_base_frame_bytes": ( + f"{item.mean_base_frame_bytes:.3f}" + ), + "mean_roi_frame_bytes": ( + f"{item.mean_roi_frame_bytes:.3f}" + ), + "mean_composite_frame_bytes": ( + f"{item.mean_composite_frame_bytes:.3f}" + ), + "p95_composite_frame_bytes": ( + f"{item.p95_composite_frame_bytes:.3f}" + ), + "max_composite_frame_bytes": ( + item.max_composite_frame_bytes + ), + "base_bitrate_kbps": ( + f"{item.base_bitrate_kbps:.6f}" + ), + "roi_bitrate_kbps": ( + f"{item.roi_bitrate_kbps:.6f}" + ), + "total_payload_bitrate_kbps": ( + f"{item.total_payload_bitrate_kbps:.6f}" + ), + "channel_rate_fec_2_3_kbps": ( + f"{item.channel_rate_fec_2_3_kbps:.6f}" + ), + "channel_rate_fec_1_2_kbps": ( + f"{item.channel_rate_fec_1_2_kbps:.6f}" + ), + "timestamp_mismatch_count": ( + item.timestamp_mismatch_count + ), + "frame_id_mismatch_count": ( + item.frame_id_mismatch_count + ), + } + ) + + +def format_statistics_line(item: ProfileStatistics) -> str: + """ + Формирует одну подробную строку текстового отчёта. + """ + + return ( + f"{item.profile_name}: " + f"{item.fps:.3f} fps; " + f"BASE={item.base_width}x{item.base_height}, " + f"Q{item.base_quality}, " + f"frames={item.selected_base_frames}, " + f"bytes={item.total_base_bytes}, " + f"bitrate={item.base_bitrate_kbps:.6f} kbit/s; " + f"ROI={item.roi_width}x{item.roi_height}, " + f"Q{item.roi_quality}, " + f"frames={item.selected_roi_frames}, " + f"bytes={item.total_roi_bytes}, " + f"bitrate={item.roi_bitrate_kbps:.6f} kbit/s; " + f"total_bytes={item.total_payload_bytes}, " + f"payload={item.total_payload_bitrate_kbps:.6f} kbit/s; " + f"channel FEC 2/3=" + f"{item.channel_rate_fec_2_3_kbps:.6f} kbit/s; " + f"channel FEC 1/2=" + f"{item.channel_rate_fec_1_2_kbps:.6f} kbit/s; " + f"sync_frames={item.synchronized_composite_frames}; " + f"timestamp_mismatch=" + f"{item.timestamp_mismatch_count}; " + f"frame_id_mismatch={item.frame_id_mismatch_count}" + ) + + +def write_report( + statistics: list[ProfileStatistics], + source_width: int, + source_height: int, + source_fps: float, + source_frame_count: int, + source_duration_seconds: float, + preview_path: Path, + fallback_used: bool, +) -> None: + """ + Сохраняет UTF-8 отчёт без автоматического выбора профиля. + """ + + source_roi = normalized_roi_to_pixels( + source_width, + source_height, + ) + lines = [ + "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 недостаточна для динамической сцены.", + "", + f"Исходное видео: {SOURCE_VIDEO_PATH}", + f"Разрешение: {source_width}x{source_height}", + f"FPS: {source_fps:.6f}", + f"Кадров: {source_frame_count}", + f"Длительность: {source_duration_seconds:.6f} с", + "ROI: " + f"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]}.", + "", + "Все профили синхронные: BASE и ROI формируются из " + "одного source frame, получают единый timestamp и " + "composite frame ID и публикуются атомарно.", + "", + "Фактические результаты:", + ] + lines.extend( + format_statistics_line(item) + for item in statistics + ) + lines.extend( + [ + "", + "Оценки channel rate иллюстративны: к payload " + "добавлено 10% служебных данных, затем применена " + "оценка FEC 2/3 или FEC 1/2.", + "Эти значения не являются окончательной архитектурой " + "радиоканала.", + "", + f"Preview: {preview_path}", + "Формат: " + + ("AVI/MJPG fallback." if fallback_used else "MP4/mp4v."), + "", + "Предупреждение: оценки пока не включают окончательную " + "модуляцию, полосу сигнала, интерливинг, повторы и " + "команды управления.", + "Программа не выбирает лучший профиль автоматически.", + "Следующий шаг: ручной выбор пользователя после " + "просмотра preview-видео.", + "", + ] + ) + + REPORT_PATH.write_text( + "\n".join(lines), + encoding="utf-8", + ) + + +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], +]: + """ + Проверяет метаданные и три контрольных кадра preview. + """ + + if not preview_path.exists() or 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, height) != (OUTPUT_WIDTH, OUTPUT_HEIGHT): + raise RuntimeError( + f"Некорректное разрешение preview: {width}x{height}." + ) + + if frame_count != source_frame_count: + raise RuntimeError( + "Число кадров preview не совпало с исходным." + ) + + if ( + abs(duration_seconds - source_duration_seconds) + > 1.0 / fps + FRAME_TIME_EPSILON_SECONDS + ): + raise RuntimeError( + "Длительность preview отличается более чем на кадр." + ) + + if not all( + frame_result[0] + for frame_result in [ + first_frame, + middle_frame, + last_frame, + ] + ): + raise RuntimeError( + "Не удалось прочитать контрольный кадр preview." + ) + + return ( + width, + height, + fps, + frame_count, + duration_seconds, + first_frame, + middle_frame, + last_frame, + ) + + +def main() -> None: + """ + Выполняет два прохода Lab027D и проверяет результаты. + """ + + print("Reading source video metadata...") + ( + source_width, + source_height, + source_fps, + source_frame_count, + source_duration_seconds, + source_file_size_bytes, + ) = read_video_metadata(SOURCE_VIDEO_PATH) + + print(f" Source: {SOURCE_VIDEO_PATH}") + print(f" File size: {source_file_size_bytes} bytes") + 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("First pass: measuring synchronized JPEG payload...") + first_measurements, first_states = process_first_pass( + SOURCE_VIDEO_PATH, + profiles, + source_width, + source_height, + source_fps, + source_frame_count, + ) + statistics = calculate_statistics( + profiles, + first_measurements, + first_states, + source_duration_seconds, + ) + validate_statistics(statistics) + + print("Second pass: writing preview...") + ( + preview_path, + fallback_used, + written_frame_count, + second_measurements, + second_states, + ) = write_preview( + SOURCE_VIDEO_PATH, + profiles, + statistics, + source_width, + source_height, + source_fps, + source_frame_count, + ) + + compare_passes( + profiles, + first_measurements, + second_measurements, + first_states, + second_states, + ) + + second_statistics = calculate_statistics( + profiles, + second_measurements, + second_states, + source_duration_seconds, + ) + validate_statistics(second_statistics) + + if statistics != second_statistics: + raise RuntimeError( + "Итоговая статистика двух проходов не совпала." + ) + + ( + preview_width, + preview_height, + preview_fps, + verified_frame_count, + preview_duration_seconds, + first_frame, + middle_frame, + last_frame, + ) = verify_preview( + preview_path, + source_frame_count, + source_duration_seconds, + ) + + OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True) + save_csv(statistics) + write_report( + statistics, + source_width, + source_height, + source_fps, + source_frame_count, + source_duration_seconds, + preview_path, + fallback_used, + ) + + print("") + print("Measured profiles:") + + for item in statistics: + print( + f" {item.profile_name}: " + f"updates={item.synchronized_composite_frames}, " + f"BASE={item.base_bitrate_kbps:.6f}, " + f"ROI={item.roi_bitrate_kbps:.6f}, " + f"payload={item.total_payload_bitrate_kbps:.6f}, " + f"FEC 2/3={item.channel_rate_fec_2_3_kbps:.6f}, " + f"FEC 1/2={item.channel_rate_fec_1_2_kbps:.6f} " + "kbit/s, " + f"timestamp mismatch={item.timestamp_mismatch_count}, " + f"frame ID mismatch={item.frame_id_mismatch_count}" + ) + + print("") + print("Two-pass JPEG sizes and statistics: identical") + print(f"Preview: {preview_path}") + print(f"Fallback used: {fallback_used}") + print(f"Preview 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: {verified_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("Lab027D completed successfully.") + + +if __name__ == "__main__": + main() diff --git a/tests/lab027e_operator_view_preview.py b/tests/lab027e_operator_view_preview.py new file mode 100644 index 0000000..cddba20 --- /dev/null +++ b/tests/lab027e_operator_view_preview.py @@ -0,0 +1,1575 @@ +""" +Lab027E. Операторское preview синхронного BASE + ROI. + +Лабораторная имитирует изображение на операторской стороне для +предварительно выбранного профиля BASE 240x135 Q23 и ROI 320x180 +Q33 при трёх атомарных обновлениях в секунду. Содержимое исходной +сцены воспроизводится со скоростью 0.5x. + +JPEG-кодирование выполняется только при обновлении составного +изображения. Отдельные JPEG-файлы не создаются. Первый проход +измеряет payload, второй повторяет расписание и записывает preview. +""" + +from __future__ import annotations + +import csv +from dataclasses import dataclass +from pathlib import Path + +import cv2 +import numpy as np + + +SOURCE_VIDEO_PATH = Path("data/raw/lab026_rover_source.mp4") + +OUTPUT_DIRECTORY = Path("data/processed/lab027e") +CSV_PATH = OUTPUT_DIRECTORY / "lab027e_profile.csv" +REPORT_PATH = OUTPUT_DIRECTORY / "lab027e_report.txt" + +PREVIEW_DIRECTORY = Path("data/raw/lab027e_previews") +MP4_PREVIEW_PATH = ( + PREVIEW_DIRECTORY / "lab027e_operator_view_0_5x.mp4" +) +AVI_PREVIEW_PATH = ( + PREVIEW_DIRECTORY / "lab027e_operator_view_0_5x.avi" +) + +PROFILE_NAME = "sync_3fps_base240_q23_roi320_q33_speed_0_5x" + +PLAYBACK_SPEED_FACTOR = 0.5 +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 + +COMPOSITE_WIDTH = 640 +COMPOSITE_HEIGHT = 360 +OUTPUT_WIDTH = 1280 +OUTPUT_HEIGHT = 720 +OUTPUT_FPS = 30.0 + +SERVICE_OVERHEAD_FACTOR = 1.10 +FEC_RATE_TWO_THIRDS = 2.0 / 3.0 +FEC_RATE_ONE_HALF = 0.5 + +FRAME_TIME_EPSILON_SECONDS = 1e-9 + +CSV_FIELD_NAMES = [ + "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", +] + + +@dataclass(frozen=True) +class OperatorProfile: + """ + Описывает выбранный операторский видеорежим. + """ + + profile_name: str + playback_speed_factor: float + composite_fps: float + base_width: int + base_height: int + base_quality: int + roi_width: int + roi_height: int + roi_quality: int + + @property + def composite_period_seconds(self) -> float: + """ + Возвращает период обновления составного изображения. + """ + + return 1.0 / self.composite_fps + + +@dataclass +class CompositeState: + """ + Хранит последнее атомарно опубликованное изображение. + """ + + next_composite_time: float = 0.0 + last_composite_update_time: float = float("-inf") + composite_frame_id: int = -1 + source_frame_index: int = -1 + source_timestamp: float = 0.0 + latest_composite: np.ndarray | None = None + selected_composite_frames: int = 0 + timestamp_mismatch_count: int = 0 + frame_id_mismatch_count: int = 0 + + +@dataclass(frozen=True) +class ProfileStatistics: + """ + Содержит итоговую строку измерений Lab027E. + """ + + profile_name: str + playback_speed_factor: float + source_width: int + source_height: int + source_fps: float + source_frames: int + source_duration_s: float + output_width: int + output_height: int + output_fps: float + output_frames: int + output_duration_s: float + composite_fps: float + base_width: int + base_height: int + base_quality: int + roi_width: int + roi_height: int + roi_quality: int + selected_composite_frames: int + total_base_bytes: int + total_roi_bytes: int + total_payload_bytes: int + mean_base_frame_bytes: float + mean_roi_frame_bytes: float + mean_composite_frame_bytes: float + p95_composite_frame_bytes: float + max_composite_frame_bytes: int + base_bitrate_kbps: float + roi_bitrate_kbps: float + total_payload_bitrate_kbps: float + channel_rate_fec_2_3_kbps: float + channel_rate_fec_1_2_kbps: float + timestamp_mismatch_count: int + frame_id_mismatch_count: int + + +Measurements = dict[str, list[int]] + + +def build_profile() -> OperatorProfile: + """ + Создаёт единственный профиль Lab027E. + """ + + return OperatorProfile( + profile_name=PROFILE_NAME, + playback_speed_factor=PLAYBACK_SPEED_FACTOR, + composite_fps=COMPOSITE_FPS, + base_width=BASE_WIDTH, + base_height=BASE_HEIGHT, + base_quality=BASE_QUALITY, + roi_width=ROI_WIDTH, + roi_height=ROI_HEIGHT, + roi_quality=ROI_QUALITY, + ) + + +def read_video_metadata( + source_path: Path, +) -> tuple[int, int, float, int, float, int]: + """ + Читает метаданные исходного MP4 без изменения файла. + """ + + if not source_path.exists(): + raise FileNotFoundError( + 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("Некорректное разрешение исходного видео.") + + if fps <= 0.0 or frame_count <= 0: + raise RuntimeError( + "Некорректные FPS или число кадров исходного видео." + ) + + duration_seconds = frame_count / fps + file_size_bytes = source_path.stat().st_size + + if duration_seconds <= 0.0 or file_size_bytes <= 0: + raise RuntimeError( + "Некорректная длительность или размер исходного видео." + ) + + return ( + width, + height, + fps, + frame_count, + duration_seconds, + file_size_bytes, + ) + + +def calculate_output_frame_count( + source_frame_count: int, + source_fps: float, + output_fps: float, + playback_speed_factor: float, +) -> int: + """ + Вычисляет число выходных кадров без выхода за исходный диапазон. + + Последний допустимый output_frame_index должен давать + source_frame_index не больше source_frame_count - 1. + """ + + if ( + source_frame_count <= 0 + or source_fps <= 0.0 + or output_fps <= 0.0 + or playback_speed_factor <= 0.0 + ): + raise ValueError( + "Параметры временной модели должны быть положительными." + ) + + source_frames_per_output_frame = ( + playback_speed_factor * source_fps / output_fps + ) + output_frame_count = int( + np.ceil( + source_frame_count + / source_frames_per_output_frame + ) + ) + + if output_frame_count <= 0: + raise RuntimeError( + "Рассчитано некорректное число выходных кадров." + ) + + last_source_index = source_frame_index_for_output( + output_frame_count - 1, + output_fps, + playback_speed_factor, + source_fps, + source_frame_count, + ) + next_unclamped_source_index = int( + np.floor( + output_frame_count + / output_fps + * playback_speed_factor + * source_fps + + FRAME_TIME_EPSILON_SECONDS + ) + ) + + if last_source_index >= source_frame_count: + raise RuntimeError( + "Последний выходной кадр вышел за исходный диапазон." + ) + + if next_unclamped_source_index < source_frame_count: + raise RuntimeError( + "Рассчитано недостаточное число выходных кадров." + ) + + return output_frame_count + + +def source_frame_index_for_output( + output_frame_index: int, + output_fps: float, + playback_speed_factor: float, + source_fps: float, + source_frame_count: int, +) -> int: + """ + Детерминированно сопоставляет выходной кадр исходному. + """ + + output_time = output_frame_index / output_fps + source_time = output_time * playback_speed_factor + source_frame_index = int( + np.floor( + source_time * source_fps + + FRAME_TIME_EPSILON_SECONDS + ) + ) + + return min( + max(source_frame_index, 0), + source_frame_count - 1, + ) + + +def normalized_roi_to_pixels( + width: int, + height: int, +) -> tuple[int, int, int, int]: + """ + Переводит нормализованные координаты ROI в пиксели. + """ + + 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)) + + if not ( + 0 <= x_min < x_max <= width + and 0 <= y_min < y_max <= height + ): + raise RuntimeError("Расчётная ROI выходит за границы кадра.") + + return x_min, y_min, x_max, y_max + + +def should_update( + output_time: float, + next_composite_time: float, +) -> bool: + """ + Проверяет наступление времени composite-обновления. + """ + + return ( + output_time + FRAME_TIME_EPSILON_SECONDS + >= next_composite_time + ) + + +def read_source_frame_sequentially( + capture: cv2.VideoCapture, + current_source_frame_index: int, + current_source_frame: np.ndarray | None, + target_source_frame_index: int, +) -> tuple[int, np.ndarray]: + """ + Последовательно читает исходник до заданного индекса. + + Случайный seek не используется. Если запрошен тот же индекс, + возвращается уже прочитанный кадр. + """ + + if target_source_frame_index < current_source_frame_index: + raise RuntimeError( + "Последовательное чтение не допускает возврат назад." + ) + + while current_source_frame_index < target_source_frame_index: + frame_read, source_frame = capture.read() + + if not frame_read or source_frame is None: + raise RuntimeError( + "Исходное видео закончилось до целевого кадра " + f"{target_source_frame_index}." + ) + + current_source_frame_index += 1 + current_source_frame = source_frame + + if current_source_frame is None: + raise RuntimeError("Не удалось получить исходный кадр.") + + return current_source_frame_index, current_source_frame + + +def encode_decode_jpeg( + grayscale_image: np.ndarray, + quality: int, +) -> tuple[int, np.ndarray]: + """ + Кодирует grayscale-изображение в JPEG в памяти и декодирует. + """ + + encoded, jpeg_buffer = cv2.imencode( + ".jpg", + grayscale_image, + [ + int(cv2.IMWRITE_JPEG_QUALITY), + int(quality), + ], + ) + + if not encoded: + raise RuntimeError("OpenCV не смог закодировать JPEG.") + + decoded = cv2.imdecode( + jpeg_buffer, + cv2.IMREAD_GRAYSCALE, + ) + + if decoded is None: + raise RuntimeError("OpenCV не смог декодировать JPEG.") + + return int(jpeg_buffer.nbytes), decoded + + +def encode_base( + source_frame: np.ndarray, + profile: OperatorProfile, +) -> tuple[int, np.ndarray]: + """ + Формирует BASE заданного размера и JPEG Quality. + """ + + grayscale = cv2.cvtColor( + source_frame, + cv2.COLOR_BGR2GRAY, + ) + resized = cv2.resize( + grayscale, + (profile.base_width, profile.base_height), + interpolation=cv2.INTER_AREA, + ) + + return encode_decode_jpeg(resized, profile.base_quality) + + +def encode_roi( + source_frame: np.ndarray, + source_roi: tuple[int, int, int, int], + profile: OperatorProfile, +) -> tuple[int, np.ndarray]: + """ + Формирует ROI заданного размера и JPEG Quality. + """ + + x_min, y_min, x_max, y_max = source_roi + roi = source_frame[y_min:y_max, x_min:x_max] + + if roi.size == 0: + raise RuntimeError("Получена пустая ROI.") + + grayscale = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) + resized = cv2.resize( + grayscale, + (profile.roi_width, profile.roi_height), + interpolation=cv2.INTER_AREA, + ) + + return encode_decode_jpeg(resized, profile.roi_quality) + + +def build_composite( + decoded_base: np.ndarray, + decoded_roi: np.ndarray, +) -> np.ndarray: + """ + Собирает фактическое составное изображение 640x360. + + ROI заменяет соответствующую область напрямую, без рамки, + смешивания и сглаживания границы. + """ + + base_large = cv2.resize( + decoded_base, + (COMPOSITE_WIDTH, COMPOSITE_HEIGHT), + interpolation=cv2.INTER_LINEAR, + ) + composite = cv2.cvtColor( + base_large, + cv2.COLOR_GRAY2BGR, + ) + + x_min, y_min, x_max, y_max = normalized_roi_to_pixels( + COMPOSITE_WIDTH, + COMPOSITE_HEIGHT, + ) + roi_large = cv2.resize( + decoded_roi, + (x_max - x_min, y_max - y_min), + interpolation=cv2.INTER_LINEAR, + ) + composite[y_min:y_max, x_min:x_max] = cv2.cvtColor( + roi_large, + cv2.COLOR_GRAY2BGR, + ) + + return composite + + +def create_measurements() -> Measurements: + """ + Создаёт пустые измерения одного прохода. + """ + + return { + "base": [], + "roi": [], + "composite": [], + "source_indices": [], + } + + +def update_composite_state( + source_frame: np.ndarray, + source_roi: tuple[int, int, int, int], + source_frame_index: int, + source_fps: float, + output_time: float, + profile: OperatorProfile, + state: CompositeState, + measurements: Measurements, +) -> None: + """ + Кодирует обе части и атомарно публикует составной кадр. + """ + + next_composite_frame_id = state.composite_frame_id + 1 + source_timestamp = source_frame_index / source_fps + + base_source_frame_index = source_frame_index + roi_source_frame_index = source_frame_index + base_source_timestamp = source_timestamp + roi_source_timestamp = source_timestamp + base_composite_frame_id = next_composite_frame_id + roi_composite_frame_id = next_composite_frame_id + + base_size, decoded_base = encode_base(source_frame, profile) + roi_size, decoded_roi = encode_roi( + source_frame, + source_roi, + profile, + ) + + if base_source_timestamp != roi_source_timestamp: + state.timestamp_mismatch_count += 1 + + if ( + base_source_frame_index != roi_source_frame_index + or base_composite_frame_id != roi_composite_frame_id + ): + state.frame_id_mismatch_count += 1 + + composite = build_composite(decoded_base, decoded_roi) + + # Все отображаемые поля заменяются только после готовности + # BASE, ROI и реконструированного изображения. + state.latest_composite = composite + state.composite_frame_id = next_composite_frame_id + state.source_frame_index = source_frame_index + state.source_timestamp = source_timestamp + state.last_composite_update_time = output_time + state.next_composite_time += ( + profile.composite_period_seconds + ) + state.selected_composite_frames += 1 + + measurements["base"].append(base_size) + measurements["roi"].append(roi_size) + measurements["composite"].append(base_size + roi_size) + measurements["source_indices"].append(source_frame_index) + + +def process_first_pass( + source_path: Path, + profile: OperatorProfile, + source_width: int, + source_height: int, + source_fps: float, + source_frame_count: int, + output_frame_count: int, +) -> tuple[Measurements, CompositeState]: + """ + Измеряет JPEG payload без создания видео. + """ + + measurements = create_measurements() + state = CompositeState() + source_roi = normalized_roi_to_pixels( + source_width, + source_height, + ) + capture = cv2.VideoCapture(str(source_path)) + + if not capture.isOpened(): + raise RuntimeError( + f"OpenCV не смог открыть видео: {source_path}" + ) + + current_source_frame_index = -1 + current_source_frame: np.ndarray | None = None + + try: + for output_frame_index in range(output_frame_count): + output_time = output_frame_index / OUTPUT_FPS + + if not should_update( + output_time, + state.next_composite_time, + ): + continue + + target_source_frame_index = ( + source_frame_index_for_output( + output_frame_index, + OUTPUT_FPS, + profile.playback_speed_factor, + source_fps, + source_frame_count, + ) + ) + ( + current_source_frame_index, + current_source_frame, + ) = read_source_frame_sequentially( + capture, + current_source_frame_index, + current_source_frame, + target_source_frame_index, + ) + update_composite_state( + current_source_frame, + source_roi, + current_source_frame_index, + source_fps, + output_time, + profile, + state, + measurements, + ) + + if state.selected_composite_frames % 25 == 0: + print( + " First pass composite frames: " + f"{state.selected_composite_frames}" + ) + finally: + capture.release() + + if state.latest_composite is None: + raise RuntimeError( + "Первый проход не сформировал составных кадров." + ) + + return measurements, state + + +def calculate_statistics( + profile: OperatorProfile, + measurements: Measurements, + state: CompositeState, + source_width: int, + source_height: int, + source_fps: float, + source_frame_count: int, + source_duration_seconds: float, + output_frame_count: int, +) -> ProfileStatistics: + """ + Рассчитывает bitrate по длительности замедленного preview. + """ + + base_sizes = measurements["base"] + roi_sizes = measurements["roi"] + composite_sizes = measurements["composite"] + + if not base_sizes or not roi_sizes or not composite_sizes: + raise RuntimeError("Получены пустые измерения JPEG.") + + if not ( + len(base_sizes) + == len(roi_sizes) + == len(composite_sizes) + == len(measurements["source_indices"]) + == state.selected_composite_frames + ): + raise RuntimeError( + "Число измерений и составных кадров не совпало." + ) + + output_duration_seconds = output_frame_count / OUTPUT_FPS + total_base_bytes = int(sum(base_sizes)) + total_roi_bytes = int(sum(roi_sizes)) + total_payload_bytes = total_base_bytes + total_roi_bytes + + base_bitrate_kbps = ( + total_base_bytes + * 8.0 + / output_duration_seconds + / 1000.0 + ) + roi_bitrate_kbps = ( + total_roi_bytes + * 8.0 + / output_duration_seconds + / 1000.0 + ) + total_payload_bitrate_kbps = ( + total_payload_bytes + * 8.0 + / output_duration_seconds + / 1000.0 + ) + + if not np.isclose( + total_payload_bitrate_kbps, + base_bitrate_kbps + roi_bitrate_kbps, + rtol=0.0, + atol=1e-12, + ): + raise RuntimeError( + "Суммарный bitrate не равен BASE + ROI." + ) + + channel_rate_fec_2_3_kbps = ( + total_payload_bitrate_kbps + * SERVICE_OVERHEAD_FACTOR + / FEC_RATE_TWO_THIRDS + ) + channel_rate_fec_1_2_kbps = ( + total_payload_bitrate_kbps + * SERVICE_OVERHEAD_FACTOR + / FEC_RATE_ONE_HALF + ) + + return ProfileStatistics( + profile_name=profile.profile_name, + playback_speed_factor=profile.playback_speed_factor, + source_width=source_width, + source_height=source_height, + source_fps=source_fps, + source_frames=source_frame_count, + source_duration_s=source_duration_seconds, + output_width=OUTPUT_WIDTH, + output_height=OUTPUT_HEIGHT, + output_fps=OUTPUT_FPS, + output_frames=output_frame_count, + output_duration_s=output_duration_seconds, + composite_fps=profile.composite_fps, + base_width=profile.base_width, + base_height=profile.base_height, + base_quality=profile.base_quality, + roi_width=profile.roi_width, + roi_height=profile.roi_height, + roi_quality=profile.roi_quality, + selected_composite_frames=( + state.selected_composite_frames + ), + total_base_bytes=total_base_bytes, + total_roi_bytes=total_roi_bytes, + total_payload_bytes=total_payload_bytes, + mean_base_frame_bytes=float(np.mean(base_sizes)), + mean_roi_frame_bytes=float(np.mean(roi_sizes)), + mean_composite_frame_bytes=float( + np.mean(composite_sizes) + ), + p95_composite_frame_bytes=float( + np.percentile(composite_sizes, 95) + ), + max_composite_frame_bytes=int(max(composite_sizes)), + base_bitrate_kbps=base_bitrate_kbps, + roi_bitrate_kbps=roi_bitrate_kbps, + total_payload_bitrate_kbps=( + total_payload_bitrate_kbps + ), + channel_rate_fec_2_3_kbps=( + channel_rate_fec_2_3_kbps + ), + channel_rate_fec_1_2_kbps=( + channel_rate_fec_1_2_kbps + ), + timestamp_mismatch_count=( + state.timestamp_mismatch_count + ), + frame_id_mismatch_count=( + state.frame_id_mismatch_count + ), + ) + + +def draw_minimal_status( + preview_frame: np.ndarray, + profile: OperatorProfile, + statistics: ProfileStatistics, + composite_age_seconds: float, +) -> None: + """ + Рисует небольшой непрозрачный статусный блок слева сверху. + """ + + block_width = 410 + block_height = 142 + cv2.rectangle( + preview_frame, + (0, 0), + (block_width, block_height), + (0, 0, 0), + cv2.FILLED, + ) + + lines = [ + f"{profile.composite_fps:.0f} fps", + f"BASE {profile.base_width}x{profile.base_height} " + f"Q{profile.base_quality}", + f"ROI {profile.roi_width}x{profile.roi_height} " + f"Q{profile.roi_quality}", + f"playback {profile.playback_speed_factor:.1f}x", + "payload " + f"{statistics.total_payload_bitrate_kbps:.3f} kbit/s", + f"age {composite_age_seconds:.3f} s", + ] + + for line_index, text in enumerate(lines): + cv2.putText( + preview_frame, + text, + (10, 22 + line_index * 22), + cv2.FONT_HERSHEY_SIMPLEX, + 0.55, + (255, 255, 255), + 1, + cv2.LINE_AA, + ) + + +def open_preview_writer() -> tuple[cv2.VideoWriter, Path, bool]: + """ + Открывает MP4/mp4v либо разрешённый AVI/MJPG fallback. + """ + + PREVIEW_DIRECTORY.mkdir(parents=True, exist_ok=True) + + mp4_writer = cv2.VideoWriter( + str(MP4_PREVIEW_PATH), + cv2.VideoWriter_fourcc(*"mp4v"), + OUTPUT_FPS, + (OUTPUT_WIDTH, OUTPUT_HEIGHT), + ) + + 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), + ) + + if not avi_writer.isOpened(): + avi_writer.release() + raise RuntimeError( + "Не удалось открыть MP4/mp4v и AVI/MJPG writer." + ) + + return avi_writer, AVI_PREVIEW_PATH, True + + +def write_operator_preview( + source_path: Path, + profile: OperatorProfile, + statistics: ProfileStatistics, + source_width: int, + source_height: int, + source_fps: float, + source_frame_count: int, + output_frame_count: int, +) -> tuple[ + Path, + bool, + Measurements, + CompositeState, +]: + """ + Повторяет расписание и записывает одно полноэкранное preview. + """ + + measurements = create_measurements() + state = CompositeState() + source_roi = normalized_roi_to_pixels( + source_width, + source_height, + ) + capture = cv2.VideoCapture(str(source_path)) + + if not capture.isOpened(): + raise RuntimeError( + f"OpenCV не смог открыть видео: {source_path}" + ) + + writer, preview_path, fallback_used = open_preview_writer() + current_source_frame_index = -1 + current_source_frame: np.ndarray | None = None + + try: + for output_frame_index in range(output_frame_count): + output_time = output_frame_index / OUTPUT_FPS + + if should_update( + output_time, + state.next_composite_time, + ): + target_source_frame_index = ( + source_frame_index_for_output( + output_frame_index, + OUTPUT_FPS, + profile.playback_speed_factor, + source_fps, + source_frame_count, + ) + ) + ( + current_source_frame_index, + current_source_frame, + ) = read_source_frame_sequentially( + capture, + current_source_frame_index, + current_source_frame, + target_source_frame_index, + ) + update_composite_state( + current_source_frame, + source_roi, + current_source_frame_index, + source_fps, + output_time, + profile, + state, + measurements, + ) + + if state.latest_composite is None: + raise RuntimeError( + "Отсутствует составное изображение для preview." + ) + + preview_frame = cv2.resize( + state.latest_composite, + (OUTPUT_WIDTH, OUTPUT_HEIGHT), + interpolation=cv2.INTER_LINEAR, + ) + composite_age_seconds = max( + 0.0, + output_time - state.last_composite_update_time, + ) + draw_minimal_status( + preview_frame, + profile, + statistics, + composite_age_seconds, + ) + writer.write(preview_frame) + + written_frames = output_frame_index + 1 + + if ( + written_frames % 200 == 0 + or written_frames == output_frame_count + ): + print( + " Preview frames: " + f"{written_frames}/{output_frame_count}" + ) + finally: + capture.release() + writer.release() + + return ( + preview_path, + fallback_used, + measurements, + state, + ) + + +def compare_passes( + first_measurements: Measurements, + second_measurements: Measurements, + first_state: CompositeState, + second_state: CompositeState, +) -> None: + """ + Проверяет точное совпадение обоих проходов. + """ + + for measurement_name in [ + "base", + "roi", + "composite", + "source_indices", + ]: + if ( + first_measurements[measurement_name] + != second_measurements[measurement_name] + ): + raise RuntimeError( + "Проходы различаются по измерению: " + f"{measurement_name}." + ) + + if ( + first_state.selected_composite_frames + != second_state.selected_composite_frames + or first_state.timestamp_mismatch_count + != second_state.timestamp_mismatch_count + or first_state.frame_id_mismatch_count + != second_state.frame_id_mismatch_count + ): + raise RuntimeError( + "Итоговые состояния двух проходов не совпали." + ) + + +def validate_statistics( + statistics: ProfileStatistics, +) -> None: + """ + Проверяет синхронизацию и арифметику итоговой строки. + """ + + if statistics.selected_composite_frames <= 0: + raise RuntimeError("Число составных обновлений равно нулю.") + + if ( + statistics.timestamp_mismatch_count != 0 + or statistics.frame_id_mismatch_count != 0 + ): + raise RuntimeError("Обнаружен mismatch синхронного профиля.") + + if ( + statistics.total_payload_bytes + != statistics.total_base_bytes + + statistics.total_roi_bytes + ): + raise RuntimeError("Некорректная сумма payload bytes.") + + +def save_csv(statistics: ProfileStatistics) -> None: + """ + Сохраняет одну полную строку результатов в UTF-8 CSV. + """ + + with CSV_PATH.open( + "w", + encoding="utf-8", + newline="", + ) as csv_file: + writer = csv.DictWriter( + csv_file, + fieldnames=CSV_FIELD_NAMES, + ) + writer.writeheader() + writer.writerow( + { + "profile_name": statistics.profile_name, + "playback_speed_factor": ( + f"{statistics.playback_speed_factor:.6f}" + ), + "source_width": statistics.source_width, + "source_height": statistics.source_height, + "source_fps": f"{statistics.source_fps:.6f}", + "source_frames": statistics.source_frames, + "source_duration_s": ( + f"{statistics.source_duration_s:.6f}" + ), + "output_width": statistics.output_width, + "output_height": statistics.output_height, + "output_fps": f"{statistics.output_fps:.6f}", + "output_frames": statistics.output_frames, + "output_duration_s": ( + f"{statistics.output_duration_s:.6f}" + ), + "composite_fps": ( + f"{statistics.composite_fps:.6f}" + ), + "base_width": statistics.base_width, + "base_height": statistics.base_height, + "base_quality": statistics.base_quality, + "roi_width": statistics.roi_width, + "roi_height": statistics.roi_height, + "roi_quality": statistics.roi_quality, + "selected_composite_frames": ( + statistics.selected_composite_frames + ), + "total_base_bytes": statistics.total_base_bytes, + "total_roi_bytes": statistics.total_roi_bytes, + "total_payload_bytes": ( + statistics.total_payload_bytes + ), + "mean_base_frame_bytes": ( + f"{statistics.mean_base_frame_bytes:.3f}" + ), + "mean_roi_frame_bytes": ( + f"{statistics.mean_roi_frame_bytes:.3f}" + ), + "mean_composite_frame_bytes": ( + f"{statistics.mean_composite_frame_bytes:.3f}" + ), + "p95_composite_frame_bytes": ( + f"{statistics.p95_composite_frame_bytes:.3f}" + ), + "max_composite_frame_bytes": ( + statistics.max_composite_frame_bytes + ), + "base_bitrate_kbps": ( + f"{statistics.base_bitrate_kbps:.6f}" + ), + "roi_bitrate_kbps": ( + f"{statistics.roi_bitrate_kbps:.6f}" + ), + "total_payload_bitrate_kbps": ( + f"{statistics.total_payload_bitrate_kbps:.6f}" + ), + "channel_rate_fec_2_3_kbps": ( + f"{statistics.channel_rate_fec_2_3_kbps:.6f}" + ), + "channel_rate_fec_1_2_kbps": ( + f"{statistics.channel_rate_fec_1_2_kbps:.6f}" + ), + "timestamp_mismatch_count": ( + statistics.timestamp_mismatch_count + ), + "frame_id_mismatch_count": ( + statistics.frame_id_mismatch_count + ), + } + ) + + +def write_report( + statistics: ProfileStatistics, + preview_path: Path, + fallback_used: bool, +) -> None: + """ + Сохраняет учебный отчёт без окончательного утверждения режима. + """ + + lines = [ + "Lab027E. Операторское preview при playback 0.5x", + "", + "Результаты ручной оценки Lab027D:", + "- 3 fps достаточно на исходном быстром видео;", + "- Q20/Q30 приемлем;", + "- Q23/Q33 приемлем;", + "- разница Q23/Q33 и Q25/Q35 практически отсутствует.", + "", + "Предварительно выбран профиль Q23/Q33.", + "От Q25/Q35 предварительно отказались, поскольку " + "визуальный выигрыш почти отсутствует.", + "Профиль не считается окончательно утверждённым до " + "ручного просмотра этого preview.", + "", + "Исходная сцена замедлена для имитации более медленного " + "движения ровера и оценки удобства управления.", + f"Коэффициент playback: " + f"{statistics.playback_speed_factor:.3f}x.", + "Предупреждение: реальная скорость исходного автомобиля " + "неизвестна.", + "", + "Исходное видео:", + f"- путь: {SOURCE_VIDEO_PATH};", + f"- разрешение: " + f"{statistics.source_width}x{statistics.source_height};", + f"- FPS: {statistics.source_fps:.6f};", + f"- кадров: {statistics.source_frames};", + f"- длительность: " + f"{statistics.source_duration_s:.6f} с.", + "", + "Выходное preview:", + f"- разрешение: " + f"{statistics.output_width}x{statistics.output_height};", + f"- FPS: {statistics.output_fps:.6f};", + f"- кадров: {statistics.output_frames};", + f"- длительность: " + f"{statistics.output_duration_s:.6f} с;", + f"- composite FPS: {statistics.composite_fps:.6f}.", + "", + "Профиль:", + f"- BASE {statistics.base_width}x" + f"{statistics.base_height} Q{statistics.base_quality};", + f"- ROI {statistics.roi_width}x" + f"{statistics.roi_height} Q{statistics.roi_quality};", + f"- составных обновлений: " + f"{statistics.selected_composite_frames}.", + "", + "Фактические скорости:", + f"- BASE: {statistics.base_bitrate_kbps:.6f} kbit/s;", + f"- ROI: {statistics.roi_bitrate_kbps:.6f} kbit/s;", + "- total payload: " + f"{statistics.total_payload_bitrate_kbps:.6f} kbit/s;", + "- channel rate FEC 2/3: " + f"{statistics.channel_rate_fec_2_3_kbps:.6f} kbit/s;", + "- channel rate FEC 1/2: " + f"{statistics.channel_rate_fec_1_2_kbps:.6f} kbit/s.", + "", + "Оценки канальной скорости иллюстративны и не являются " + "окончательной архитектурой радиоканала.", + "Mismatch-проверка:", + "- timestamp mismatch: " + f"{statistics.timestamp_mismatch_count};", + "- frame ID mismatch: " + f"{statistics.frame_id_mismatch_count}.", + "", + f"Preview: {preview_path}", + "Формат: " + + ("AVI/MJPG fallback." if fallback_used else "MP4/mp4v."), + "Следующий шаг: ручная оценка удобства управления " + "пользователем.", + "", + ] + + REPORT_PATH.write_text( + "\n".join(lines), + encoding="utf-8", + ) + + +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, + expected_frame_count: int, + expected_duration_seconds: float, +) -> tuple[ + int, + int, + float, + int, + float, + tuple[bool, tuple[int, ...] | None], + tuple[bool, tuple[int, ...] | None], + tuple[bool, tuple[int, ...] | None], +]: + """ + Проверяет метаданные и три контрольных кадра preview. + """ + + if not preview_path.exists() or 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, height) != (OUTPUT_WIDTH, OUTPUT_HEIGHT): + raise RuntimeError( + f"Некорректное разрешение preview: {width}x{height}." + ) + + if frame_count != expected_frame_count: + raise RuntimeError( + "Число кадров preview не совпало с расчётным." + ) + + if ( + abs(duration_seconds - expected_duration_seconds) + > 1.0 / fps + FRAME_TIME_EPSILON_SECONDS + ): + raise RuntimeError( + "Длительность preview отличается более чем на кадр." + ) + + if not all( + frame_result[0] + for frame_result in [ + first_frame, + middle_frame, + last_frame, + ] + ): + raise RuntimeError( + "Не удалось прочитать контрольный кадр preview." + ) + + return ( + width, + height, + fps, + frame_count, + duration_seconds, + first_frame, + middle_frame, + last_frame, + ) + + +def main() -> None: + """ + Выполняет оба прохода Lab027E и проверяет результат. + """ + + print("Reading source video metadata...") + ( + source_width, + source_height, + source_fps, + source_frame_count, + source_duration_seconds, + source_file_size_bytes, + ) = read_video_metadata(SOURCE_VIDEO_PATH) + profile = build_profile() + output_frame_count = calculate_output_frame_count( + source_frame_count, + source_fps, + OUTPUT_FPS, + profile.playback_speed_factor, + ) + output_duration_seconds = ( + output_frame_count / OUTPUT_FPS + ) + + print(f" Source: {SOURCE_VIDEO_PATH}") + print(f" File size: {source_file_size_bytes} bytes") + print(f" Resolution: {source_width}x{source_height}") + print(f" FPS: {source_fps:.6f}") + print(f" Frames: {source_frame_count}") + print(f" Source duration: {source_duration_seconds:.6f} s") + print( + f" Playback speed: " + f"{profile.playback_speed_factor:.3f}x" + ) + print(f" Output frames: {output_frame_count}") + print(f" Output duration: {output_duration_seconds:.6f} s") + + print("First pass: measuring JPEG payload...") + first_measurements, first_state = process_first_pass( + SOURCE_VIDEO_PATH, + profile, + source_width, + source_height, + source_fps, + source_frame_count, + output_frame_count, + ) + statistics = calculate_statistics( + profile, + first_measurements, + first_state, + source_width, + source_height, + source_fps, + source_frame_count, + source_duration_seconds, + output_frame_count, + ) + validate_statistics(statistics) + + print("Second pass: writing operator preview...") + ( + preview_path, + fallback_used, + second_measurements, + second_state, + ) = write_operator_preview( + SOURCE_VIDEO_PATH, + profile, + statistics, + source_width, + source_height, + source_fps, + source_frame_count, + output_frame_count, + ) + compare_passes( + first_measurements, + second_measurements, + first_state, + second_state, + ) + + second_statistics = calculate_statistics( + profile, + second_measurements, + second_state, + source_width, + source_height, + source_fps, + source_frame_count, + source_duration_seconds, + output_frame_count, + ) + validate_statistics(second_statistics) + + if statistics != second_statistics: + raise RuntimeError( + "Итоговая статистика двух проходов не совпала." + ) + + ( + preview_width, + preview_height, + preview_fps, + verified_frame_count, + preview_duration_seconds, + first_frame, + middle_frame, + last_frame, + ) = verify_preview( + preview_path, + output_frame_count, + output_duration_seconds, + ) + + OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True) + save_csv(statistics) + write_report( + statistics, + preview_path, + fallback_used, + ) + + print("") + print("Measured profile:") + print(f" {statistics.profile_name}") + print( + " Composite updates: " + f"{statistics.selected_composite_frames}" + ) + print( + f" BASE bitrate: " + f"{statistics.base_bitrate_kbps:.6f} kbit/s" + ) + print( + f" ROI bitrate: " + f"{statistics.roi_bitrate_kbps:.6f} kbit/s" + ) + print( + f" Total payload bitrate: " + f"{statistics.total_payload_bitrate_kbps:.6f} kbit/s" + ) + print( + f" Channel rate FEC 2/3: " + f"{statistics.channel_rate_fec_2_3_kbps:.6f} kbit/s" + ) + print( + f" Channel rate FEC 1/2: " + f"{statistics.channel_rate_fec_1_2_kbps:.6f} kbit/s" + ) + print( + f" Timestamp mismatch: " + f"{statistics.timestamp_mismatch_count}" + ) + print( + f" Frame ID mismatch: " + f"{statistics.frame_id_mismatch_count}" + ) + print("") + print( + "Two-pass JPEG sizes, source indices and statistics: " + "identical" + ) + print(f"Preview: {preview_path}") + print(f"Fallback used: {fallback_used}") + print(f"Preview 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"Preview frames: {verified_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("Lab027E completed successfully.") + + +if __name__ == "__main__": + main()