diff --git a/data/processed/lab026/lab026_bitrate_vs_fps.png b/data/processed/lab026/lab026_bitrate_vs_fps.png new file mode 100644 index 0000000..5dcd674 Binary files /dev/null and b/data/processed/lab026/lab026_bitrate_vs_fps.png differ diff --git a/data/processed/lab026/lab026_bitrate_vs_jpeg_quality.png b/data/processed/lab026/lab026_bitrate_vs_jpeg_quality.png new file mode 100644 index 0000000..6168431 Binary files /dev/null and b/data/processed/lab026/lab026_bitrate_vs_jpeg_quality.png differ diff --git a/data/processed/lab026/lab026_bitrate_vs_resolution.png b/data/processed/lab026/lab026_bitrate_vs_resolution.png new file mode 100644 index 0000000..ccab5ee Binary files /dev/null and b/data/processed/lab026/lab026_bitrate_vs_resolution.png differ diff --git a/data/processed/lab026/lab026_candidate_profiles.png b/data/processed/lab026/lab026_candidate_profiles.png new file mode 100644 index 0000000..ceea557 Binary files /dev/null and b/data/processed/lab026/lab026_candidate_profiles.png differ diff --git a/data/processed/lab026/lab026_profiles.csv b/data/processed/lab026/lab026_profiles.csv new file mode 100644 index 0000000..d8053ac --- /dev/null +++ b/data/processed/lab026/lab026_profiles.csv @@ -0,0 +1,33 @@ +experiment,profile_name,width,height,target_fps,actual_fps,color_mode,jpeg_quality,source_duration_s,selected_frames,total_bytes,mean_frame_bytes,median_frame_bytes,p95_frame_bytes,max_frame_bytes,payload_bitrate_bps,payload_bitrate_kbps,source_file_bitrate_kbps,reduction_ratio,percent_of_source,mean_psnr_db +fps_sweep,fps_30,640,360,30.000000,30.000000,grayscale,50,20.766667,623,10219569,16403.803,16282.000,18471.500,21081,3936912.616,3936.912616,3001.342536,0.762359,131.171720,37.349661 +fps_sweep,fps_10,640,360,10.000000,10.016051,grayscale,50,20.766667,208,3641634,17507.856,17509.500,18958.400,21081,1402876.661,1402.876661,3001.342536,2.139420,46.741638,36.369592 +fps_sweep,fps_5,640,360,5.000000,5.008026,grayscale,50,20.766667,104,1822387,17522.952,17547.000,18910.400,21081,702043.146,702.043146,3001.342536,4.275154,23.390970,36.352519 +fps_sweep,fps_2,640,360,2.000000,2.022472,grayscale,50,20.766667,42,737435,17557.976,17476.500,18926.800,21081,284084.109,284.084109,3001.342536,10.564979,9.465234,36.319782 +fps_sweep,fps_1,640,360,1.000000,1.011236,grayscale,50,20.766667,21,369317,17586.524,17483.000,18935.000,21081,142273.002,142.273002,3001.342536,21.095658,4.740312,36.287711 +fps_sweep,fps_0_5,640,360,0.500000,0.529695,grayscale,50,20.766667,11,191626,17420.545,17040.000,19774.000,21081,73820.610,73.820610,3001.342536,40.657244,2.459586,36.371684 +fps_sweep,fps_0_2,640,360,0.200000,0.240770,grayscale,50,20.766667,5,91496,18299.200,18467.000,20651.800,21081,35247.255,35.247255,3001.342536,85.151100,1.174383,35.860330 +resolution_sweep,resolution_1280x720,1280,720,5.000000,5.008026,grayscale,50,20.766667,104,5506628,52948.346,52634.000,57821.150,73994,2121333.419,2121.333419,3001.342536,1.414838,70.679484,39.607731 +resolution_sweep,resolution_960x540,960,540,5.000000,5.008026,grayscale,50,20.766667,104,3411900,32806.731,32634.500,35501.600,41897,1314375.602,1314.375602,3001.342536,2.283474,43.792922,38.632041 +resolution_sweep,resolution_640x360,640,360,5.000000,5.008026,grayscale,50,20.766667,104,1822387,17522.952,17547.000,18910.400,21081,702043.146,702.043146,3001.342536,4.275154,23.390970,36.352519 +resolution_sweep,resolution_480x270,480,270,5.000000,5.008026,grayscale,50,20.766667,104,1120944,10778.308,10786.000,11463.200,12395,431824.334,431.824334,3001.342536,6.950378,14.387706,36.172087 +resolution_sweep,resolution_320x180,320,180,5.000000,5.008026,grayscale,50,20.766667,104,590018,5673.250,5679.000,5995.750,6191,227294.254,227.294254,3001.342536,13.204656,7.573086,35.069392 +resolution_sweep,resolution_160x90,160,90,5.000000,5.008026,grayscale,50,20.766667,104,210050,2019.712,2017.000,2103.400,2156,80918.138,80.918138,3001.342536,37.091097,2.696065,33.585930 +quality_sweep,quality_color_q90,640,360,5.000000,5.008026,color,90,20.766667,104,5486654,52756.288,52645.500,57012.750,66227,2113638.780,2113.638780,3001.342536,1.419988,70.423111,39.694424 +quality_sweep,quality_color_q70,640,360,5.000000,5.008026,color,70,20.766667,104,2901304,27897.154,27815.500,30080.600,33877,1117677.303,1117.677303,3001.342536,2.685339,37.239245,36.029412 +quality_sweep,quality_color_q50,640,360,5.000000,5.008026,color,50,20.766667,104,2137169,20549.702,20514.000,22026.250,24396,823307.480,823.307480,3001.342536,3.645470,27.431307,34.439250 +quality_sweep,quality_color_q30,640,360,5.000000,5.008026,color,30,20.766667,104,1572112,15116.462,15105.500,16167.600,17386,605629.021,605.629021,3001.342536,4.955744,20.178604,32.766254 +quality_sweep,quality_color_q20,640,360,5.000000,5.008026,color,20,20.766667,104,1224535,11774.375,11797.000,12633.050,13098,471730.979,471.730979,3001.342536,6.362403,15.717332,31.298703 +quality_sweep,quality_grayscale_q90,640,360,5.000000,5.008026,grayscale,90,20.766667,104,4697004,45163.500,44959.000,48937.750,57535,1809439.743,1809.439743,3001.342536,1.658714,60.287679,42.574562 +quality_sweep,quality_grayscale_q70,640,360,5.000000,5.008026,grayscale,70,20.766667,104,2484781,23892.125,23853.000,25744.950,29385,957219.005,957.219005,3001.342536,3.135482,31.893028,38.031656 +quality_sweep,quality_grayscale_q50,640,360,5.000000,5.008026,grayscale,50,20.766667,104,1822387,17522.952,17547.000,18910.400,21081,702043.146,702.043146,3001.342536,4.275154,23.390970,36.352519 +quality_sweep,quality_grayscale_q30,640,360,5.000000,5.008026,grayscale,30,20.766667,104,1328179,12770.952,12821.500,13834.150,14903,511658.042,511.658042,3001.342536,5.865915,17.047639,34.701951 +quality_sweep,quality_grayscale_q20,640,360,5.000000,5.008026,grayscale,20,20.766667,104,1017957,9788.048,9832.500,10597.450,11027,392150.369,392.150369,3001.342536,7.653550,13.065832,33.304362 +candidate_profiles,candidate_640x360_gray_5fps_q50,640,360,5.000000,5.008026,grayscale,50,20.766667,104,1822387,17522.952,17547.000,18910.400,21081,702043.146,702.043146,3001.342536,4.275154,23.390970,36.352519 +candidate_profiles,candidate_640x360_gray_2fps_q40,640,360,2.000000,2.022472,grayscale,40,20.766667,42,639531,15226.929,15187.500,16381.750,18122,246368.283,246.368283,3001.342536,12.182341,8.208603,35.588581 +candidate_profiles,candidate_320x180_color_5fps_q50,320,180,5.000000,5.008026,color,50,20.766667,104,717676,6900.731,6919.500,7309.850,7475,276472.295,276.472295,3001.342536,10.855853,9.211621,32.413850 +candidate_profiles,candidate_320x180_gray_5fps_q50,320,180,5.000000,5.008026,grayscale,50,20.766667,104,590018,5673.250,5679.000,5995.750,6191,227294.254,227.294254,3001.342536,13.204656,7.573086,35.069392 +candidate_profiles,candidate_320x180_gray_2fps_q40,320,180,2.000000,2.022472,grayscale,40,20.766667,42,208403,4961.976,4965.000,5224.700,5400,80283.660,80.283660,3001.342536,37.384227,2.674925,34.362114 +candidate_profiles,candidate_320x180_gray_1fps_q35,320,180,1.000000,1.011236,grayscale,35,20.766667,21,96733,4606.333,4611.000,4836.000,4992,37264.719,37.264719,3001.342536,80.541129,1.241602,34.035117 +candidate_profiles,candidate_320x180_gray_0_5fps_q35,320,180,0.500000,0.529695,grayscale,35,20.766667,11,50124,4556.727,4493.000,4914.000,4992,19309.406,19.309406,3001.342536,155.434223,0.643359,34.165852 +candidate_profiles,candidate_320x180_gray_0_2fps_q35,320,180,0.200000,0.240770,grayscale,35,20.766667,5,23618,4723.600,4815.000,4960.800,4992,9098.427,9.098427,3001.342536,329.874884,0.303145,33.741044 +candidate_profiles,candidate_160x90_gray_1fps_q30,160,90,1.000000,1.011236,grayscale,30,20.766667,21,32743,1559.190,1561.000,1623.000,1632,12613.676,12.613676,3001.342536,237.943530,0.420268,32.031480 diff --git a/data/processed/lab026/lab026_report.txt b/data/processed/lab026/lab026_report.txt new file mode 100644 index 0000000..8900721 --- /dev/null +++ b/data/processed/lab026/lab026_report.txt @@ -0,0 +1,82 @@ +Lab026. Исследование уменьшения видеопотока для радиоуправления ровером + +Цель: исследовать уменьшение полезной нагрузки видеоканала для передачи через ретранслятор. +Исходный файл: data\raw\lab026_rover_source.mp4 +Размер исходного файла: 7790985 байт +Исходное разрешение: 1280x720 +Исходный FPS: 30.000000 +Число кадров: 623 +Длительность: 20.766667 с +Приблизительный исходный файловый битрейт: 3001.343 кбит/с + +Исследуется независимое покадровое JPEG-сжатие. JPEG хранится и декодируется только в памяти. +Рассчитанный bitrate учитывает только JPEG payload. +Не учитываются заголовки радиопакетов, CRC, FEC, преамбула, интервалы, повторы и команды управления. + +Результаты fps_sweep: +fps_30: 640x360, 30 кадр/с, grayscale, Q=50, кадров=623, payload=3936.913 кбит/с, PSNR=37.350 дБ, уменьшение=0.76x +fps_10: 640x360, 10 кадр/с, grayscale, Q=50, кадров=208, payload=1402.877 кбит/с, PSNR=36.370 дБ, уменьшение=2.14x +fps_5: 640x360, 5 кадр/с, grayscale, Q=50, кадров=104, payload=702.043 кбит/с, PSNR=36.353 дБ, уменьшение=4.28x +fps_2: 640x360, 2 кадр/с, grayscale, Q=50, кадров=42, payload=284.084 кбит/с, PSNR=36.320 дБ, уменьшение=10.56x +fps_1: 640x360, 1 кадр/с, grayscale, Q=50, кадров=21, payload=142.273 кбит/с, PSNR=36.288 дБ, уменьшение=21.10x +fps_0_5: 640x360, 0.5 кадр/с, grayscale, Q=50, кадров=11, payload=73.821 кбит/с, PSNR=36.372 дБ, уменьшение=40.66x +fps_0_2: 640x360, 0.2 кадр/с, grayscale, Q=50, кадров=5, payload=35.247 кбит/с, PSNR=35.860 дБ, уменьшение=85.15x + +Результаты resolution_sweep: +resolution_1280x720: 1280x720, 5 кадр/с, grayscale, Q=50, кадров=104, payload=2121.333 кбит/с, PSNR=39.608 дБ, уменьшение=1.41x +resolution_960x540: 960x540, 5 кадр/с, grayscale, Q=50, кадров=104, payload=1314.376 кбит/с, PSNR=38.632 дБ, уменьшение=2.28x +resolution_640x360: 640x360, 5 кадр/с, grayscale, Q=50, кадров=104, payload=702.043 кбит/с, PSNR=36.353 дБ, уменьшение=4.28x +resolution_480x270: 480x270, 5 кадр/с, grayscale, Q=50, кадров=104, payload=431.824 кбит/с, PSNR=36.172 дБ, уменьшение=6.95x +resolution_320x180: 320x180, 5 кадр/с, grayscale, Q=50, кадров=104, payload=227.294 кбит/с, PSNR=35.069 дБ, уменьшение=13.20x +resolution_160x90: 160x90, 5 кадр/с, grayscale, Q=50, кадров=104, payload=80.918 кбит/с, PSNR=33.586 дБ, уменьшение=37.09x + +Результаты quality_sweep: +quality_color_q90: 640x360, 5 кадр/с, color, Q=90, кадров=104, payload=2113.639 кбит/с, PSNR=39.694 дБ, уменьшение=1.42x +quality_color_q70: 640x360, 5 кадр/с, color, Q=70, кадров=104, payload=1117.677 кбит/с, PSNR=36.029 дБ, уменьшение=2.69x +quality_color_q50: 640x360, 5 кадр/с, color, Q=50, кадров=104, payload=823.307 кбит/с, PSNR=34.439 дБ, уменьшение=3.65x +quality_color_q30: 640x360, 5 кадр/с, color, Q=30, кадров=104, payload=605.629 кбит/с, PSNR=32.766 дБ, уменьшение=4.96x +quality_color_q20: 640x360, 5 кадр/с, color, Q=20, кадров=104, payload=471.731 кбит/с, PSNR=31.299 дБ, уменьшение=6.36x +quality_grayscale_q90: 640x360, 5 кадр/с, grayscale, Q=90, кадров=104, payload=1809.440 кбит/с, PSNR=42.575 дБ, уменьшение=1.66x +quality_grayscale_q70: 640x360, 5 кадр/с, grayscale, Q=70, кадров=104, payload=957.219 кбит/с, PSNR=38.032 дБ, уменьшение=3.14x +quality_grayscale_q50: 640x360, 5 кадр/с, grayscale, Q=50, кадров=104, payload=702.043 кбит/с, PSNR=36.353 дБ, уменьшение=4.28x +quality_grayscale_q30: 640x360, 5 кадр/с, grayscale, Q=30, кадров=104, payload=511.658 кбит/с, PSNR=34.702 дБ, уменьшение=5.87x +quality_grayscale_q20: 640x360, 5 кадр/с, grayscale, Q=20, кадров=104, payload=392.150 кбит/с, PSNR=33.304 дБ, уменьшение=7.65x + +Результаты candidate_profiles: +candidate_640x360_gray_5fps_q50: 640x360, 5 кадр/с, grayscale, Q=50, кадров=104, payload=702.043 кбит/с, PSNR=36.353 дБ, уменьшение=4.28x +candidate_640x360_gray_2fps_q40: 640x360, 2 кадр/с, grayscale, Q=40, кадров=42, payload=246.368 кбит/с, PSNR=35.589 дБ, уменьшение=12.18x +candidate_320x180_color_5fps_q50: 320x180, 5 кадр/с, color, Q=50, кадров=104, payload=276.472 кбит/с, PSNR=32.414 дБ, уменьшение=10.86x +candidate_320x180_gray_5fps_q50: 320x180, 5 кадр/с, grayscale, Q=50, кадров=104, payload=227.294 кбит/с, PSNR=35.069 дБ, уменьшение=13.20x +candidate_320x180_gray_2fps_q40: 320x180, 2 кадр/с, grayscale, Q=40, кадров=42, payload=80.284 кбит/с, PSNR=34.362 дБ, уменьшение=37.38x +candidate_320x180_gray_1fps_q35: 320x180, 1 кадр/с, grayscale, Q=35, кадров=21, payload=37.265 кбит/с, PSNR=34.035 дБ, уменьшение=80.54x +candidate_320x180_gray_0_5fps_q35: 320x180, 0.5 кадр/с, grayscale, Q=35, кадров=11, payload=19.309 кбит/с, PSNR=34.166 дБ, уменьшение=155.43x +candidate_320x180_gray_0_2fps_q35: 320x180, 0.2 кадр/с, grayscale, Q=35, кадров=5, payload=9.098 кбит/с, PSNR=33.741 дБ, уменьшение=329.87x +candidate_160x90_gray_1fps_q30: 160x90, 1 кадр/с, grayscale, Q=30, кадров=21, payload=12.614 кбит/с, PSNR=32.031 дБ, уменьшение=237.94x + +Candidate-профили с payload bitrate не более 100 кбит/с: +candidate_320x180_gray_2fps_q40: 320x180, 2 кадр/с, grayscale, Q=40, кадров=42, payload=80.284 кбит/с, PSNR=34.362 дБ, уменьшение=37.38x +candidate_320x180_gray_1fps_q35: 320x180, 1 кадр/с, grayscale, Q=35, кадров=21, payload=37.265 кбит/с, PSNR=34.035 дБ, уменьшение=80.54x +candidate_320x180_gray_0_5fps_q35: 320x180, 0.5 кадр/с, grayscale, Q=35, кадров=11, payload=19.309 кбит/с, PSNR=34.166 дБ, уменьшение=155.43x +candidate_320x180_gray_0_2fps_q35: 320x180, 0.2 кадр/с, grayscale, Q=35, кадров=5, payload=9.098 кбит/с, PSNR=33.741 дБ, уменьшение=329.87x +candidate_160x90_gray_1fps_q30: 160x90, 1 кадр/с, grayscale, Q=30, кадров=21, payload=12.614 кбит/с, PSNR=32.031 дБ, уменьшение=237.94x + +Candidate-профили с payload bitrate не более 50 кбит/с: +candidate_320x180_gray_1fps_q35: 320x180, 1 кадр/с, grayscale, Q=35, кадров=21, payload=37.265 кбит/с, PSNR=34.035 дБ, уменьшение=80.54x +candidate_320x180_gray_0_5fps_q35: 320x180, 0.5 кадр/с, grayscale, Q=35, кадров=11, payload=19.309 кбит/с, PSNR=34.166 дБ, уменьшение=155.43x +candidate_320x180_gray_0_2fps_q35: 320x180, 0.2 кадр/с, grayscale, Q=35, кадров=5, payload=9.098 кбит/с, PSNR=33.741 дБ, уменьшение=329.87x +candidate_160x90_gray_1fps_q30: 160x90, 1 кадр/с, grayscale, Q=30, кадров=21, payload=12.614 кбит/с, PSNR=32.031 дБ, уменьшение=237.94x + +Candidate-профили с payload bitrate не более 20 кбит/с: +candidate_320x180_gray_0_5fps_q35: 320x180, 0.5 кадр/с, grayscale, Q=35, кадров=11, payload=19.309 кбит/с, PSNR=34.166 дБ, уменьшение=155.43x +candidate_320x180_gray_0_2fps_q35: 320x180, 0.2 кадр/с, grayscale, Q=35, кадров=5, payload=9.098 кбит/с, PSNR=33.741 дБ, уменьшение=329.87x +candidate_160x90_gray_1fps_q30: 160x90, 1 кадр/с, grayscale, Q=30, кадров=21, payload=12.614 кбит/с, PSNR=32.031 дБ, уменьшение=237.94x + +Candidate-профили с payload bitrate не более 10 кбит/с: +candidate_320x180_gray_0_2fps_q35: 320x180, 0.2 кадр/с, grayscale, Q=35, кадров=5, payload=9.098 кбит/с, PSNR=33.741 дБ, уменьшение=329.87x + +Профиль с минимальным измеренным битрейтом: +candidate_320x180_gray_0_2fps_q35: 320x180, 0.2 кадр/с, grayscale, Q=35, кадров=5, payload=9.098 кбит/с, PSNR=33.741 дБ, уменьшение=329.87x + +Автоматические метрики не заменяют визуальную оценку пригодности изображения для управления ровером. +Сравнительное изображение: data\processed\lab026\lab026_candidate_profiles.png +Следующий шаг: пользователь должен визуально выбрать пригодные candidate-профили. \ No newline at end of file diff --git a/data/processed/lab026b/lab026b_bitrate_vs_resolution.png b/data/processed/lab026b/lab026b_bitrate_vs_resolution.png new file mode 100644 index 0000000..89d065f Binary files /dev/null and b/data/processed/lab026b/lab026b_bitrate_vs_resolution.png differ diff --git a/data/processed/lab026b/lab026b_codec_comparison.png b/data/processed/lab026b/lab026b_codec_comparison.png new file mode 100644 index 0000000..bf802f1 Binary files /dev/null and b/data/processed/lab026b/lab026b_codec_comparison.png differ diff --git a/data/processed/lab026b/lab026b_equal_bitrate_candidates.png b/data/processed/lab026b/lab026b_equal_bitrate_candidates.png new file mode 100644 index 0000000..f8fb428 Binary files /dev/null and b/data/processed/lab026b/lab026b_equal_bitrate_candidates.png differ diff --git a/data/processed/lab026b/lab026b_profiles.csv b/data/processed/lab026b/lab026b_profiles.csv new file mode 100644 index 0000000..0e318ce --- /dev/null +++ b/data/processed/lab026b/lab026b_profiles.csv @@ -0,0 +1,31 @@ +experiment,profile_name,mode,width,height,pixel_count,target_fps,actual_fps,bits_per_pixel_before_compression,maximum_gray_levels,maximum_colors,selected_frames,total_payload_bytes,mean_frame_bytes,median_frame_bytes,p95_frame_bytes,max_frame_bytes,payload_bitrate_bps,payload_bitrate_kbps,baseline_bitrate_kbps,difference_from_baseline_kbps,percent_of_baseline_bitrate,compression_ratio_from_unpacked_source,mean_psnr_db,header_bytes_per_frame,palette_bytes_per_frame,notes +four_bit_resolution_sweep,320x180_gray8_jpeg_2fps,gray8_jpeg,320,180,57600,2.000000,2.022472,8,256,0,42,208403,4961.976,4965.000,5224.700,5400,80283.660,80.283660,80.283660,-0.000000,100.000000,11.608278,34.362114,0,0,"Grayscale 8 bit, JPEG quality 40." +four_bit_resolution_sweep,320x180_gray4_jpeg_2fps,gray4_jpeg,320,180,57600,2.000000,2.022472,4,16,0,42,229518,5464.714,5466.000,5727.250,5822,88417.849,88.417849,80.283660,8.134189,110.131812,10.540350,32.588941,0,0,"Grayscale quantized to 16 levels, restored to uint8, JPEG quality 40." +four_bit_resolution_sweep,320x180_gray4_packed_zlib_2fps,gray4_packed_zlib,320,180,57600,2.000000,2.022472,4,16,0,42,441074,10501.762,10444.000,11194.300,12317,169916.148,169.916148,80.283660,89.632488,211.644745,5.484794,34.397012,16,0,"Two 4-bit grayscale indices per byte, zlib level 9, 16-byte frame header." +four_bit_resolution_sweep,320x180_color16_png_2fps,color16_png,320,180,57600,2.000000,2.022472,4,0,16,42,371520,8845.714,8607.500,10578.350,10794,143121.669,143.121669,80.283660,62.838009,178.269986,19.534884,25.384724,0,0,"Pillow palette image, maximum 16 colors, no dithering, optimized indexed PNG." +four_bit_resolution_sweep,320x180_color16_packed_zlib_2fps,color16_packed_zlib,320,180,57600,2.000000,2.022472,4,0,16,42,364892,8687.905,8452.000,10414.800,10652,140568.347,140.568347,80.283660,60.284687,175.089609,19.889721,25.384724,16,48,"Two 4-bit palette indices per byte, zlib level 9, 16-byte header and fixed 48-byte palette." +four_bit_resolution_sweep,400x225_gray8_jpeg_2fps,gray8_jpeg,400,225,90000,2.000000,2.022472,8,256,0,42,297273,7077.929,7063.500,7473.000,7841,114519.294,114.519294,80.283660,34.235634,142.643340,12.715585,34.942886,0,0,"Grayscale 8 bit, JPEG quality 40." +four_bit_resolution_sweep,400x225_gray4_jpeg_2fps,gray4_jpeg,400,225,90000,2.000000,2.022472,4,16,0,42,332268,7911.143,7920.500,8353.700,8554,128000.514,128.000514,80.283660,47.716854,159.435324,11.376359,32.883090,0,0,"Grayscale quantized to 16 levels, restored to uint8, JPEG quality 40." +four_bit_resolution_sweep,400x225_gray4_packed_zlib_2fps,gray4_packed_zlib,400,225,90000,2.000000,2.022472,4,16,0,42,636862,15163.381,15120.500,16266.050,18356,245340.096,245.340096,80.283660,165.056436,305.591569,5.935352,34.399552,16,0,"Two 4-bit grayscale indices per byte, zlib level 9, 16-byte frame header." +four_bit_resolution_sweep,400x225_color16_png_2fps,color16_png,400,225,90000,2.000000,2.022472,4,0,16,42,528261,12577.643,12153.000,15191.650,16128,203503.435,203.503435,80.283660,123.219775,253.480515,21.466661,25.344339,0,0,"Pillow palette image, maximum 16 colors, no dithering, optimized indexed PNG." +four_bit_resolution_sweep,400x225_color16_packed_zlib_2fps,color16_packed_zlib,400,225,90000,2.000000,2.022472,4,0,16,42,520725,12398.214,11977.000,15015.100,15966,200600.321,200.600321,80.283660,120.316661,249.864444,21.777330,25.344339,16,48,"Two 4-bit palette indices per byte, zlib level 9, 16-byte header and fixed 48-byte palette." +four_bit_resolution_sweep,448x252_gray8_jpeg_2fps,gray8_jpeg,448,252,112896,2.000000,2.022472,8,256,0,42,353578,8418.524,8413.000,8885.950,9512,136209.823,136.209823,80.283660,55.926163,169.660705,13.410427,35.301071,0,0,"Grayscale 8 bit, JPEG quality 40." +four_bit_resolution_sweep,448x252_gray4_jpeg_2fps,gray4_jpeg,448,252,112896,2.000000,2.022472,4,16,0,42,397126,9455.381,9467.500,9996.400,10450,152985.939,152.985939,80.283660,72.702279,190.556757,11.939868,33.060439,0,0,"Grayscale quantized to 16 levels, restored to uint8, JPEG quality 40." +four_bit_resolution_sweep,448x252_gray4_packed_zlib_2fps,gray4_packed_zlib,448,252,112896,2.000000,2.022472,4,16,0,42,762082,18144.810,18111.500,19504.750,22433,293578.941,293.578941,80.283660,213.295281,365.677076,6.221945,34.399841,16,0,"Two 4-bit grayscale indices per byte, zlib level 9, 16-byte frame header." +four_bit_resolution_sweep,448x252_color16_png_2fps,color16_png,448,252,112896,2.000000,2.022472,4,0,16,42,626516,14917.048,14409.500,18239.600,18695,241354.478,241.354478,80.283660,161.070818,300.627149,22.704761,25.369963,0,0,"Pillow palette image, maximum 16 colors, no dithering, optimized indexed PNG." +four_bit_resolution_sweep,448x252_color16_packed_zlib_2fps,color16_packed_zlib,448,252,112896,2.000000,2.022472,4,0,16,42,618164,14718.190,14221.500,18022.000,18517,238137.014,238.137014,80.283660,157.853354,296.619529,23.011524,25.369963,16,48,"Two 4-bit palette indices per byte, zlib level 9, 16-byte header and fixed 48-byte palette." +four_bit_resolution_sweep,480x270_gray8_jpeg_2fps,gray8_jpeg,480,270,129600,2.000000,2.022472,8,256,0,42,393752,9375.048,9365.500,9919.950,10678,151686.164,151.686164,80.283660,71.402504,188.937779,13.823930,35.412999,0,0,"Grayscale 8 bit, JPEG quality 40." +four_bit_resolution_sweep,480x270_gray4_jpeg_2fps,gray4_jpeg,480,270,129600,2.000000,2.022472,4,16,0,42,443419,10557.595,10575.500,11152.550,11737,170819.518,170.819518,80.283660,90.535858,212.769969,12.275523,33.099458,0,0,"Grayscale quantized to 16 levels, restored to uint8, JPEG quality 40." +four_bit_resolution_sweep,480x270_gray4_packed_zlib_2fps,gray4_packed_zlib,480,270,129600,2.000000,2.022472,4,16,0,42,854354,20341.762,20276.500,21857.900,25369,329125.136,329.125136,80.283660,248.841476,409.952830,6.371130,34.401420,16,0,"Two 4-bit grayscale indices per byte, zlib level 9, 16-byte frame header." +four_bit_resolution_sweep,480x270_color16_png_2fps,color16_png,480,270,129600,2.000000,2.022472,4,0,16,42,701871,16711.214,16249.500,20469.850,21005,270383.692,270.383692,80.283660,190.100032,336.785458,23.265814,25.368870,0,0,"Pillow palette image, maximum 16 colors, no dithering, optimized indexed PNG." +four_bit_resolution_sweep,480x270_color16_packed_zlib_2fps,color16_packed_zlib,480,270,129600,2.000000,2.022472,4,0,16,42,693190,16504.524,16007.500,20249.750,20809,267039.486,267.039486,80.283660,186.755826,332.619971,23.557178,25.368870,16,48,"Two 4-bit palette indices per byte, zlib level 9, 16-byte header and fixed 48-byte palette." +four_bit_resolution_sweep,560x315_gray8_jpeg_2fps,gray8_jpeg,560,315,176400,2.000000,2.022472,8,256,0,42,507535,12084.167,12068.500,12860.900,13999,195519.101,195.519101,80.283660,115.235441,243.535361,14.597614,35.954946,0,0,"Grayscale 8 bit, JPEG quality 40." +four_bit_resolution_sweep,560x315_gray4_jpeg_2fps,gray4_jpeg,560,315,176400,2.000000,2.022472,4,16,0,42,575350,13698.810,13674.500,14558.450,15458,221643.660,221.643660,80.283660,141.360000,276.075679,12.877031,33.337152,0,0,"Grayscale quantized to 16 levels, restored to uint8, JPEG quality 40." +four_bit_resolution_sweep,560x315_gray4_packed_zlib_2fps,gray4_packed_zlib,560,315,176400,2.000000,2.022472,4,16,0,42,1093024,26024.381,25851.500,28055.650,33171,421068.636,421.068636,80.283660,340.784976,524.476133,6.778259,34.403027,16,0,"Two 4-bit grayscale indices per byte, zlib level 9, 16-byte frame header." +four_bit_resolution_sweep,560x315_color16_png_2fps,color16_png,560,315,176400,2.000000,2.022472,4,0,16,42,894967,21308.738,20851.500,25906.100,27345,344770.594,344.770594,80.283660,264.486934,429.440554,24.834882,25.323519,0,0,"Pillow palette image, maximum 16 colors, no dithering, optimized indexed PNG." +four_bit_resolution_sweep,560x315_color16_packed_zlib_2fps,color16_packed_zlib,560,315,176400,2.000000,2.022472,4,0,16,42,885156,21075.143,20622.000,25686.650,27166,340991.075,340.991075,80.283660,260.707415,424.732848,25.110150,25.323519,16,48,"Two 4-bit palette indices per byte, zlib level 9, 16-byte header and fixed 48-byte palette." +four_bit_resolution_sweep,640x360_gray8_jpeg_2fps,gray8_jpeg,640,360,230400,2.000000,2.022472,8,256,0,42,639531,15226.929,15187.500,16381.750,18122,246368.283,246.368283,80.283660,166.084623,306.872261,15.131088,35.588581,0,0,"Grayscale 8 bit, JPEG quality 40." +four_bit_resolution_sweep,640x360_gray4_jpeg_2fps,gray4_jpeg,640,360,230400,2.000000,2.022472,4,16,0,42,726335,17293.690,17220.500,18557.350,19938,279808.026,279.808026,80.283660,199.524366,348.524252,13.322778,33.048695,0,0,"Grayscale quantized to 16 levels, restored to uint8, JPEG quality 40." +four_bit_resolution_sweep,640x360_gray4_packed_zlib_2fps,gray4_packed_zlib,640,360,230400,2.000000,2.022472,4,16,0,42,1394769,33208.786,32974.500,35959.800,43514,537310.690,537.310690,80.283660,457.027030,669.265315,6.937923,34.404228,16,0,"Two 4-bit grayscale indices per byte, zlib level 9, 16-byte frame header." +four_bit_resolution_sweep,640x360_color16_png_2fps,color16_png,640,360,230400,2.000000,2.022472,4,0,16,42,1140488,27154.476,26267.500,33424.950,36017,439353.323,439.353323,80.283660,359.069663,547.251237,25.454367,25.259621,0,0,"Pillow palette image, maximum 16 colors, no dithering, optimized indexed PNG." +four_bit_resolution_sweep,640x360_color16_packed_zlib_2fps,color16_packed_zlib,640,360,230400,2.000000,2.022472,4,0,16,42,1128711,26874.071,25978.500,33021.450,35603,434816.437,434.816437,80.283660,354.532777,541.600167,25.719958,25.259621,16,48,"Two 4-bit palette indices per byte, zlib level 9, 16-byte header and fixed 48-byte palette." diff --git a/data/processed/lab026b/lab026b_report.txt b/data/processed/lab026b/lab026b_report.txt new file mode 100644 index 0000000..9ca5dbc --- /dev/null +++ b/data/processed/lab026b/lab026b_report.txt @@ -0,0 +1,90 @@ +Lab026B. Увеличение разрешения кадра за счёт глубины цвета 4 бита + +Цель: проверить увеличение разрешения при битрейте, близком к контрольным 80.283660 кбит/с. +Исходное видео: data\raw\lab026_rover_source.mp4 +Размер исходного видео: 7790985 байт +Исходное разрешение: 1280×720 +Исходный FPS: 30.000000 +Число кадров: 623 +Длительность: 20.766667 с + +4-битная яркость: индекс 0...15, то есть 16 уровней серого на пиксель. +4-битный индексированный цвет: один индекс 0...15 на пиксель и палитра максимум из 16 RGB-цветов. +Это не RGB444: RGB444 требует по 4 бита на каждый из трёх каналов, то есть 12 бит на пиксель. + +Режимы: +gray8_jpeg — контрольный grayscale JPEG Q40. +gray4_jpeg — 16 уровней серого, восстановление uint8, JPEG Q40. +gray4_packed_zlib — 4-битные индексы, два на байт, zlib level 9 и заголовок 16 байт. +color16_png — палитра максимум 16 цветов, без дизеринга, индексированный PNG в памяти. +color16_packed_zlib — 4-битные индексы, zlib level 9, заголовок 16 байт и фиксированная палитра 48 байт. + +Контрольный baseline: 80.283660 кбит/с. +CRC, FEC, радиозаголовки и пакетирование пока не учтены. +Статические метрики не учитывают задержку видеоканала. + +Полные результаты 30 профилей: +320x180_gray8_jpeg_2fps: mode=gray8_jpeg, 320x180, frames=42, actual_fps=2.022472, total=208403 bytes, mean=4961.976, median=4965.000, p95=5224.700, max=5400, bitrate=80.283660 kbit/s, baseline_diff=-0.000000, baseline_percent=100.000%, compression=11.608x, PSNR=34.362114 dB +320x180_gray4_jpeg_2fps: mode=gray4_jpeg, 320x180, frames=42, actual_fps=2.022472, total=229518 bytes, mean=5464.714, median=5466.000, p95=5727.250, max=5822, bitrate=88.417849 kbit/s, baseline_diff=8.134189, baseline_percent=110.132%, compression=10.540x, PSNR=32.588941 dB +320x180_gray4_packed_zlib_2fps: mode=gray4_packed_zlib, 320x180, frames=42, actual_fps=2.022472, total=441074 bytes, mean=10501.762, median=10444.000, p95=11194.300, max=12317, bitrate=169.916148 kbit/s, baseline_diff=89.632488, baseline_percent=211.645%, compression=5.485x, PSNR=34.397012 dB +320x180_color16_png_2fps: mode=color16_png, 320x180, frames=42, actual_fps=2.022472, total=371520 bytes, mean=8845.714, median=8607.500, p95=10578.350, max=10794, bitrate=143.121669 kbit/s, baseline_diff=62.838009, baseline_percent=178.270%, compression=19.535x, PSNR=25.384724 dB +320x180_color16_packed_zlib_2fps: mode=color16_packed_zlib, 320x180, frames=42, actual_fps=2.022472, total=364892 bytes, mean=8687.905, median=8452.000, p95=10414.800, max=10652, bitrate=140.568347 kbit/s, baseline_diff=60.284687, baseline_percent=175.090%, compression=19.890x, PSNR=25.384724 dB +400x225_gray8_jpeg_2fps: mode=gray8_jpeg, 400x225, frames=42, actual_fps=2.022472, total=297273 bytes, mean=7077.929, median=7063.500, p95=7473.000, max=7841, bitrate=114.519294 kbit/s, baseline_diff=34.235634, baseline_percent=142.643%, compression=12.716x, PSNR=34.942886 dB +400x225_gray4_jpeg_2fps: mode=gray4_jpeg, 400x225, frames=42, actual_fps=2.022472, total=332268 bytes, mean=7911.143, median=7920.500, p95=8353.700, max=8554, bitrate=128.000514 kbit/s, baseline_diff=47.716854, baseline_percent=159.435%, compression=11.376x, PSNR=32.883090 dB +400x225_gray4_packed_zlib_2fps: mode=gray4_packed_zlib, 400x225, frames=42, actual_fps=2.022472, total=636862 bytes, mean=15163.381, median=15120.500, p95=16266.050, max=18356, bitrate=245.340096 kbit/s, baseline_diff=165.056436, baseline_percent=305.592%, compression=5.935x, PSNR=34.399552 dB +400x225_color16_png_2fps: mode=color16_png, 400x225, frames=42, actual_fps=2.022472, total=528261 bytes, mean=12577.643, median=12153.000, p95=15191.650, max=16128, bitrate=203.503435 kbit/s, baseline_diff=123.219775, baseline_percent=253.481%, compression=21.467x, PSNR=25.344339 dB +400x225_color16_packed_zlib_2fps: mode=color16_packed_zlib, 400x225, frames=42, actual_fps=2.022472, total=520725 bytes, mean=12398.214, median=11977.000, p95=15015.100, max=15966, bitrate=200.600321 kbit/s, baseline_diff=120.316661, baseline_percent=249.864%, compression=21.777x, PSNR=25.344339 dB +448x252_gray8_jpeg_2fps: mode=gray8_jpeg, 448x252, frames=42, actual_fps=2.022472, total=353578 bytes, mean=8418.524, median=8413.000, p95=8885.950, max=9512, bitrate=136.209823 kbit/s, baseline_diff=55.926163, baseline_percent=169.661%, compression=13.410x, PSNR=35.301071 dB +448x252_gray4_jpeg_2fps: mode=gray4_jpeg, 448x252, frames=42, actual_fps=2.022472, total=397126 bytes, mean=9455.381, median=9467.500, p95=9996.400, max=10450, bitrate=152.985939 kbit/s, baseline_diff=72.702279, baseline_percent=190.557%, compression=11.940x, PSNR=33.060439 dB +448x252_gray4_packed_zlib_2fps: mode=gray4_packed_zlib, 448x252, frames=42, actual_fps=2.022472, total=762082 bytes, mean=18144.810, median=18111.500, p95=19504.750, max=22433, bitrate=293.578941 kbit/s, baseline_diff=213.295281, baseline_percent=365.677%, compression=6.222x, PSNR=34.399841 dB +448x252_color16_png_2fps: mode=color16_png, 448x252, frames=42, actual_fps=2.022472, total=626516 bytes, mean=14917.048, median=14409.500, p95=18239.600, max=18695, bitrate=241.354478 kbit/s, baseline_diff=161.070818, baseline_percent=300.627%, compression=22.705x, PSNR=25.369963 dB +448x252_color16_packed_zlib_2fps: mode=color16_packed_zlib, 448x252, frames=42, actual_fps=2.022472, total=618164 bytes, mean=14718.190, median=14221.500, p95=18022.000, max=18517, bitrate=238.137014 kbit/s, baseline_diff=157.853354, baseline_percent=296.620%, compression=23.012x, PSNR=25.369963 dB +480x270_gray8_jpeg_2fps: mode=gray8_jpeg, 480x270, frames=42, actual_fps=2.022472, total=393752 bytes, mean=9375.048, median=9365.500, p95=9919.950, max=10678, bitrate=151.686164 kbit/s, baseline_diff=71.402504, baseline_percent=188.938%, compression=13.824x, PSNR=35.412999 dB +480x270_gray4_jpeg_2fps: mode=gray4_jpeg, 480x270, frames=42, actual_fps=2.022472, total=443419 bytes, mean=10557.595, median=10575.500, p95=11152.550, max=11737, bitrate=170.819518 kbit/s, baseline_diff=90.535858, baseline_percent=212.770%, compression=12.276x, PSNR=33.099458 dB +480x270_gray4_packed_zlib_2fps: mode=gray4_packed_zlib, 480x270, frames=42, actual_fps=2.022472, total=854354 bytes, mean=20341.762, median=20276.500, p95=21857.900, max=25369, bitrate=329.125136 kbit/s, baseline_diff=248.841476, baseline_percent=409.953%, compression=6.371x, PSNR=34.401420 dB +480x270_color16_png_2fps: mode=color16_png, 480x270, frames=42, actual_fps=2.022472, total=701871 bytes, mean=16711.214, median=16249.500, p95=20469.850, max=21005, bitrate=270.383692 kbit/s, baseline_diff=190.100032, baseline_percent=336.785%, compression=23.266x, PSNR=25.368870 dB +480x270_color16_packed_zlib_2fps: mode=color16_packed_zlib, 480x270, frames=42, actual_fps=2.022472, total=693190 bytes, mean=16504.524, median=16007.500, p95=20249.750, max=20809, bitrate=267.039486 kbit/s, baseline_diff=186.755826, baseline_percent=332.620%, compression=23.557x, PSNR=25.368870 dB +560x315_gray8_jpeg_2fps: mode=gray8_jpeg, 560x315, frames=42, actual_fps=2.022472, total=507535 bytes, mean=12084.167, median=12068.500, p95=12860.900, max=13999, bitrate=195.519101 kbit/s, baseline_diff=115.235441, baseline_percent=243.535%, compression=14.598x, PSNR=35.954946 dB +560x315_gray4_jpeg_2fps: mode=gray4_jpeg, 560x315, frames=42, actual_fps=2.022472, total=575350 bytes, mean=13698.810, median=13674.500, p95=14558.450, max=15458, bitrate=221.643660 kbit/s, baseline_diff=141.360000, baseline_percent=276.076%, compression=12.877x, PSNR=33.337152 dB +560x315_gray4_packed_zlib_2fps: mode=gray4_packed_zlib, 560x315, frames=42, actual_fps=2.022472, total=1093024 bytes, mean=26024.381, median=25851.500, p95=28055.650, max=33171, bitrate=421.068636 kbit/s, baseline_diff=340.784976, baseline_percent=524.476%, compression=6.778x, PSNR=34.403027 dB +560x315_color16_png_2fps: mode=color16_png, 560x315, frames=42, actual_fps=2.022472, total=894967 bytes, mean=21308.738, median=20851.500, p95=25906.100, max=27345, bitrate=344.770594 kbit/s, baseline_diff=264.486934, baseline_percent=429.441%, compression=24.835x, PSNR=25.323519 dB +560x315_color16_packed_zlib_2fps: mode=color16_packed_zlib, 560x315, frames=42, actual_fps=2.022472, total=885156 bytes, mean=21075.143, median=20622.000, p95=25686.650, max=27166, bitrate=340.991075 kbit/s, baseline_diff=260.707415, baseline_percent=424.733%, compression=25.110x, PSNR=25.323519 dB +640x360_gray8_jpeg_2fps: mode=gray8_jpeg, 640x360, frames=42, actual_fps=2.022472, total=639531 bytes, mean=15226.929, median=15187.500, p95=16381.750, max=18122, bitrate=246.368283 kbit/s, baseline_diff=166.084623, baseline_percent=306.872%, compression=15.131x, PSNR=35.588581 dB +640x360_gray4_jpeg_2fps: mode=gray4_jpeg, 640x360, frames=42, actual_fps=2.022472, total=726335 bytes, mean=17293.690, median=17220.500, p95=18557.350, max=19938, bitrate=279.808026 kbit/s, baseline_diff=199.524366, baseline_percent=348.524%, compression=13.323x, PSNR=33.048695 dB +640x360_gray4_packed_zlib_2fps: mode=gray4_packed_zlib, 640x360, frames=42, actual_fps=2.022472, total=1394769 bytes, mean=33208.786, median=32974.500, p95=35959.800, max=43514, bitrate=537.310690 kbit/s, baseline_diff=457.027030, baseline_percent=669.265%, compression=6.938x, PSNR=34.404228 dB +640x360_color16_png_2fps: mode=color16_png, 640x360, frames=42, actual_fps=2.022472, total=1140488 bytes, mean=27154.476, median=26267.500, p95=33424.950, max=36017, bitrate=439.353323 kbit/s, baseline_diff=359.069663, baseline_percent=547.251%, compression=25.454x, PSNR=25.259621 dB +640x360_color16_packed_zlib_2fps: mode=color16_packed_zlib, 640x360, frames=42, actual_fps=2.022472, total=1128711 bytes, mean=26874.071, median=25978.500, p95=33021.450, max=35603, bitrate=434.816437 kbit/s, baseline_diff=354.532777, baseline_percent=541.600%, compression=25.720x, PSNR=25.259621 dB + +Профили 70–90 кбит/с: +320x180_gray8_jpeg_2fps: mode=gray8_jpeg, 320x180, frames=42, actual_fps=2.022472, total=208403 bytes, mean=4961.976, median=4965.000, p95=5224.700, max=5400, bitrate=80.283660 kbit/s, baseline_diff=-0.000000, baseline_percent=100.000%, compression=11.608x, PSNR=34.362114 dB +320x180_gray4_jpeg_2fps: mode=gray4_jpeg, 320x180, frames=42, actual_fps=2.022472, total=229518 bytes, mean=5464.714, median=5466.000, p95=5727.250, max=5822, bitrate=88.417849 kbit/s, baseline_diff=8.134189, baseline_percent=110.132%, compression=10.540x, PSNR=32.588941 dB + +Профили до baseline: +320x180_gray8_jpeg_2fps: mode=gray8_jpeg, 320x180, frames=42, actual_fps=2.022472, total=208403 bytes, mean=4961.976, median=4965.000, p95=5224.700, max=5400, bitrate=80.283660 kbit/s, baseline_diff=-0.000000, baseline_percent=100.000%, compression=11.608x, PSNR=34.362114 dB + +Профили до 100 кбит/с: +320x180_gray8_jpeg_2fps: mode=gray8_jpeg, 320x180, frames=42, actual_fps=2.022472, total=208403 bytes, mean=4961.976, median=4965.000, p95=5224.700, max=5400, bitrate=80.283660 kbit/s, baseline_diff=-0.000000, baseline_percent=100.000%, compression=11.608x, PSNR=34.362114 dB +320x180_gray4_jpeg_2fps: mode=gray4_jpeg, 320x180, frames=42, actual_fps=2.022472, total=229518 bytes, mean=5464.714, median=5466.000, p95=5727.250, max=5822, bitrate=88.417849 kbit/s, baseline_diff=8.134189, baseline_percent=110.132%, compression=10.540x, PSNR=32.588941 dB + +Цветные color16-профили до 100 кбит/с: +Нет. + +Профили больше 320x180 и не выше baseline: +Нет. + +Максимальное разрешение до baseline: +320x180_gray8_jpeg_2fps: mode=gray8_jpeg, 320x180, frames=42, actual_fps=2.022472, total=208403 bytes, mean=4961.976, median=4965.000, p95=5224.700, max=5400, bitrate=80.283660 kbit/s, baseline_diff=-0.000000, baseline_percent=100.000%, compression=11.608x, PSNR=34.362114 dB + +Максимальное разрешение до 100 кбит/с: +320x180_gray8_jpeg_2fps: mode=gray8_jpeg, 320x180, frames=42, actual_fps=2.022472, total=208403 bytes, mean=4961.976, median=4965.000, p95=5224.700, max=5400, bitrate=80.283660 kbit/s, baseline_diff=-0.000000, baseline_percent=100.000%, compression=11.608x, PSNR=34.362114 dB +320x180_gray4_jpeg_2fps: mode=gray4_jpeg, 320x180, frames=42, actual_fps=2.022472, total=229518 bytes, mean=5464.714, median=5466.000, p95=5727.250, max=5822, bitrate=88.417849 kbit/s, baseline_diff=8.134189, baseline_percent=110.132%, compression=10.540x, PSNR=32.588941 dB + +Сравнение gray4 и color16: +Минимальный gray4 bitrate: 88.417849 кбит/с. +Минимальный color16 bitrate: 140.568347 кбит/с. +Размер палитры packed color16: 48 байт на кадр. +Размер компактного заголовка packed-режимов: 16 байт на кадр. + +Нельзя автоматически объявлять профиль безопасным для управления ровером по одной статической метрике. +Сравнительное изображение: data\processed\lab026b\lab026b_equal_bitrate_candidates.png +Следующий шаг: ручная визуальная оценка пользователем. \ No newline at end of file diff --git a/tests/lab026_video_bitrate_benchmark.py b/tests/lab026_video_bitrate_benchmark.py new file mode 100644 index 0000000..30f2ab8 --- /dev/null +++ b/tests/lab026_video_bitrate_benchmark.py @@ -0,0 +1,1358 @@ +""" +Lab026. Исследование уменьшения видеопотока для управления ровером. + +Лабораторная исследует только полезную нагрузку покадрового JPEG. +Радиопередача, заголовки пакетов, CRC, FEC и повторные передачи +на этом этапе не моделируются. + +Исходный MP4 открывается только для чтения. Отдельные JPEG-файлы +на диск не сохраняются: кодирование и декодирование выполняются +в оперативной памяти. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import csv +import math +import textwrap + +import cv2 +import matplotlib +import numpy as np + + +# Графики сохраняются в файлы без открытия блокирующих окон. +matplotlib.use("Agg") +import matplotlib.pyplot as plt + + +# --------------------------------------------------------------------- +# Пути к исходным и выходным данным +# --------------------------------------------------------------------- + +SOURCE_VIDEO_PATH = Path("data/raw/lab026_rover_source.mp4") + +OUTPUT_DIRECTORY = Path("data/processed/lab026") + +PROFILES_CSV_PATH = OUTPUT_DIRECTORY / "lab026_profiles.csv" +REPORT_PATH = OUTPUT_DIRECTORY / "lab026_report.txt" +FPS_PLOT_PATH = OUTPUT_DIRECTORY / "lab026_bitrate_vs_fps.png" +RESOLUTION_PLOT_PATH = ( + OUTPUT_DIRECTORY / "lab026_bitrate_vs_resolution.png" +) +QUALITY_PLOT_PATH = ( + OUTPUT_DIRECTORY / "lab026_bitrate_vs_jpeg_quality.png" +) +CANDIDATE_COMPARISON_PATH = ( + OUTPUT_DIRECTORY / "lab026_candidate_profiles.png" +) + +EXPECTED_OUTPUT_PATHS = [ + PROFILES_CSV_PATH, + REPORT_PATH, + FPS_PLOT_PATH, + RESOLUTION_PLOT_PATH, + QUALITY_PLOT_PATH, + CANDIDATE_COMPARISON_PATH, +] + + +# --------------------------------------------------------------------- +# Имена экспериментов и общие параметры +# --------------------------------------------------------------------- + +EXPERIMENT_FPS_SWEEP = "fps_sweep" +EXPERIMENT_RESOLUTION_SWEEP = "resolution_sweep" +EXPERIMENT_QUALITY_SWEEP = "quality_sweep" +EXPERIMENT_CANDIDATES = "candidate_profiles" + +COLOR_MODE_COLOR = "color" +COLOR_MODE_GRAYSCALE = "grayscale" + +FRAME_TIME_EPSILON_SECONDS = 1e-9 +MAXIMUM_PSNR_DB = 100.0 + +CSV_FIELD_NAMES = [ + "experiment", + "profile_name", + "width", + "height", + "target_fps", + "actual_fps", + "color_mode", + "jpeg_quality", + "source_duration_s", + "selected_frames", + "total_bytes", + "mean_frame_bytes", + "median_frame_bytes", + "p95_frame_bytes", + "max_frame_bytes", + "payload_bitrate_bps", + "payload_bitrate_kbps", + "source_file_bitrate_kbps", + "reduction_ratio", + "percent_of_source", + "mean_psnr_db", +] + + +@dataclass(frozen=True) +class VideoProfile: + """ + Описывает один исследуемый профиль покадрового JPEG. + + Атрибут target_fps задаёт частоту выбора кадров на временной шкале + исходного видео. Разрешение и цветовой режим применяются до JPEG. + """ + + experiment: str + profile_name: str + width: int + height: int + target_fps: float + color_mode: str + jpeg_quality: int + + +@dataclass(frozen=True) +class ProfileResult: + """ + Содержит измеренные характеристики одного видеопрофиля. + """ + + experiment: str + profile_name: str + width: int + height: int + target_fps: float + actual_fps: float + color_mode: str + jpeg_quality: int + source_duration_s: float + selected_frames: int + total_bytes: int + mean_frame_bytes: float + median_frame_bytes: float + p95_frame_bytes: float + max_frame_bytes: int + payload_bitrate_bps: float + payload_bitrate_kbps: float + source_file_bitrate_kbps: float + reduction_ratio: float + percent_of_source: float + mean_psnr_db: float + + +@dataclass +class ProfileAccumulator: + """ + Хранит промежуточные значения при последовательном чтении видео. + """ + + next_sample_time: float + frame_sizes_bytes: list[int] + psnr_values_db: list[float] + + +def read_video_metadata( + source_path: Path, +) -> tuple[int, int, float, int, float, int, float]: + """ + Читает основные метаданные исходного видео через OpenCV. + + Возвращает ширину, высоту, FPS, число кадров, длительность, + размер файла и приблизительный файловый битрейт в кбит/с. + """ + + if not source_path.is_file(): + 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 + source_file_size_bytes = source_path.stat().st_size + + if source_file_size_bytes <= 0: + raise RuntimeError("Исходный видеофайл имеет нулевой размер.") + + source_file_bitrate_kbps = ( + source_file_size_bytes + * 8.0 + / duration_seconds + / 1000.0 + ) + + return ( + width, + height, + source_fps, + frame_count, + duration_seconds, + source_file_size_bytes, + source_file_bitrate_kbps, + ) + + +def format_number_for_name(value: float) -> str: + """ + Преобразует частоту кадров в безопасную часть имени профиля. + """ + + if float(value).is_integer(): + return str(int(value)) + + return str(value).replace(".", "_") + + +def build_profiles() -> list[VideoProfile]: + """ + Формирует профили четырёх экспериментов Lab026. + """ + + profiles: list[VideoProfile] = [] + + # Эксперимент A: влияние частоты кадров. + for target_fps in [30, 10, 5, 2, 1, 0.5, 0.2]: + profiles.append( + VideoProfile( + experiment=EXPERIMENT_FPS_SWEEP, + profile_name=( + "fps_" + f"{format_number_for_name(float(target_fps))}" + ), + width=640, + height=360, + target_fps=float(target_fps), + color_mode=COLOR_MODE_GRAYSCALE, + jpeg_quality=50, + ) + ) + + # Эксперимент B: влияние разрешения. + for width, height in [ + (1280, 720), + (960, 540), + (640, 360), + (480, 270), + (320, 180), + (160, 90), + ]: + profiles.append( + VideoProfile( + experiment=EXPERIMENT_RESOLUTION_SWEEP, + profile_name=f"resolution_{width}x{height}", + width=width, + height=height, + target_fps=5.0, + color_mode=COLOR_MODE_GRAYSCALE, + jpeg_quality=50, + ) + ) + + # Эксперимент C: влияние цвета и JPEG quality. + for color_mode in [ + COLOR_MODE_COLOR, + COLOR_MODE_GRAYSCALE, + ]: + for jpeg_quality in [90, 70, 50, 30, 20]: + profiles.append( + VideoProfile( + experiment=EXPERIMENT_QUALITY_SWEEP, + profile_name=( + f"quality_{color_mode}_q{jpeg_quality}" + ), + width=640, + height=360, + target_fps=5.0, + color_mode=color_mode, + jpeg_quality=jpeg_quality, + ) + ) + + # Эксперимент D: заранее заданные кандидаты радиоканала. + profiles.extend( + [ + VideoProfile( + EXPERIMENT_CANDIDATES, + "candidate_640x360_gray_5fps_q50", + 640, + 360, + 5.0, + COLOR_MODE_GRAYSCALE, + 50, + ), + VideoProfile( + EXPERIMENT_CANDIDATES, + "candidate_640x360_gray_2fps_q40", + 640, + 360, + 2.0, + COLOR_MODE_GRAYSCALE, + 40, + ), + VideoProfile( + EXPERIMENT_CANDIDATES, + "candidate_320x180_color_5fps_q50", + 320, + 180, + 5.0, + COLOR_MODE_COLOR, + 50, + ), + VideoProfile( + EXPERIMENT_CANDIDATES, + "candidate_320x180_gray_5fps_q50", + 320, + 180, + 5.0, + COLOR_MODE_GRAYSCALE, + 50, + ), + VideoProfile( + EXPERIMENT_CANDIDATES, + "candidate_320x180_gray_2fps_q40", + 320, + 180, + 2.0, + COLOR_MODE_GRAYSCALE, + 40, + ), + VideoProfile( + EXPERIMENT_CANDIDATES, + "candidate_320x180_gray_1fps_q35", + 320, + 180, + 1.0, + COLOR_MODE_GRAYSCALE, + 35, + ), + VideoProfile( + EXPERIMENT_CANDIDATES, + "candidate_320x180_gray_0_5fps_q35", + 320, + 180, + 0.5, + COLOR_MODE_GRAYSCALE, + 35, + ), + VideoProfile( + EXPERIMENT_CANDIDATES, + "candidate_320x180_gray_0_2fps_q35", + 320, + 180, + 0.2, + COLOR_MODE_GRAYSCALE, + 35, + ), + VideoProfile( + EXPERIMENT_CANDIDATES, + "candidate_160x90_gray_1fps_q30", + 160, + 90, + 1.0, + COLOR_MODE_GRAYSCALE, + 30, + ), + ] + ) + + return profiles + + +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 prepare_frame( + source_frame: np.ndarray, + profile: VideoProfile, +) -> np.ndarray: + """ + Изменяет разрешение кадра и применяет заданный цветовой режим. + """ + + resized_frame = cv2.resize( + source_frame, + (profile.width, profile.height), + interpolation=cv2.INTER_AREA, + ) + + if profile.color_mode == COLOR_MODE_GRAYSCALE: + return cv2.cvtColor( + resized_frame, + cv2.COLOR_BGR2GRAY, + ) + + if profile.color_mode == COLOR_MODE_COLOR: + return resized_frame + + raise RuntimeError( + f"Неизвестный цветовой режим: {profile.color_mode}" + ) + + +def encode_decode_jpeg( + prepared_frame: np.ndarray, + profile: VideoProfile, +) -> tuple[int, np.ndarray]: + """ + Кодирует и декодирует JPEG только в оперативной памяти. + + Возвращает размер JPEG в байтах и восстановленное изображение. + """ + + encoding_succeeded, encoded_jpeg = cv2.imencode( + ".jpg", + prepared_frame, + [ + cv2.IMWRITE_JPEG_QUALITY, + profile.jpeg_quality, + ], + ) + + if not encoding_succeeded or encoded_jpeg is None: + raise RuntimeError( + "JPEG encoding завершился ошибкой для профиля " + f"{profile.profile_name}." + ) + + decode_mode = ( + cv2.IMREAD_GRAYSCALE + if profile.color_mode == COLOR_MODE_GRAYSCALE + else cv2.IMREAD_COLOR + ) + + decoded_frame = cv2.imdecode(encoded_jpeg, decode_mode) + + if decoded_frame is None: + raise RuntimeError( + "JPEG decoding завершился ошибкой для профиля " + f"{profile.profile_name}." + ) + + if decoded_frame.shape != prepared_frame.shape: + raise RuntimeError( + "Размер восстановленного JPEG не совпадает с эталоном " + f"для профиля {profile.profile_name}." + ) + + return int(encoded_jpeg.size), decoded_frame + + +def calculate_psnr( + reference_frame: np.ndarray, + decoded_frame: np.ndarray, +) -> float: + """ + Рассчитывает PSNR между подготовленным кадром и JPEG-декодированием. + """ + + if reference_frame.shape != decoded_frame.shape: + raise RuntimeError( + "Невозможно рассчитать PSNR для разных размеров кадров." + ) + + difference = ( + reference_frame.astype(np.float64) + - decoded_frame.astype(np.float64) + ) + mean_squared_error = float(np.mean(difference ** 2)) + + if mean_squared_error == 0.0: + return MAXIMUM_PSNR_DB + + psnr_db = 10.0 * math.log10( + (255.0 ** 2) / mean_squared_error + ) + + return min(psnr_db, MAXIMUM_PSNR_DB) + + +def process_profiles( + source_path: Path, + profiles: list[VideoProfile], + source_fps: float, + source_frame_count: int, + source_duration_seconds: float, + source_file_bitrate_kbps: float, +) -> list[ProfileResult]: + """ + За один последовательный проход измеряет все профили Lab026. + """ + + if not profiles: + raise RuntimeError("Список видеопрофилей пуст.") + + accumulators = { + profile.profile_name: ProfileAccumulator( + next_sample_time=0.0, + frame_sizes_bytes=[], + psnr_values_db=[], + ) + for profile in profiles + } + + if len(accumulators) != len(profiles): + raise RuntimeError("Обнаружены повторяющиеся имена профилей.") + + 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"OpenCV вернул пустой кадр с индексом {frame_index}." + ) + + frame_time_seconds = frame_index / source_fps + + for profile in profiles: + accumulator = accumulators[profile.profile_name] + + if not should_sample_frame( + frame_time_seconds, + accumulator.next_sample_time, + ): + continue + + prepared_frame = prepare_frame( + source_frame, + profile, + ) + jpeg_size_bytes, decoded_frame = ( + encode_decode_jpeg( + prepared_frame, + profile, + ) + ) + psnr_db = calculate_psnr( + prepared_frame, + decoded_frame, + ) + + accumulator.frame_sizes_bytes.append( + jpeg_size_bytes + ) + accumulator.psnr_values_db.append(psnr_db) + accumulator.next_sample_time += ( + 1.0 / profile.target_fps + ) + + 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("OpenCV не прочитал ни одного кадра.") + + if frame_index != source_frame_count: + print( + "Предупреждение: фактически прочитанное число кадров " + f"({frame_index}) отличается от метаданных " + f"({source_frame_count})." + ) + + results: list[ProfileResult] = [] + + for profile in profiles: + accumulator = accumulators[profile.profile_name] + + if not accumulator.frame_sizes_bytes: + raise RuntimeError( + "Не выбран ни один кадр для профиля " + f"{profile.profile_name}." + ) + + frame_sizes = np.asarray( + accumulator.frame_sizes_bytes, + dtype=np.float64, + ) + psnr_values = np.asarray( + accumulator.psnr_values_db, + dtype=np.float64, + ) + + selected_frames = len(accumulator.frame_sizes_bytes) + total_bytes = int( + sum(accumulator.frame_sizes_bytes) + ) + actual_fps = ( + selected_frames / source_duration_seconds + ) + payload_bitrate_bps = ( + total_bytes + * 8.0 + / source_duration_seconds + ) + payload_bitrate_kbps = payload_bitrate_bps / 1000.0 + + if payload_bitrate_kbps <= 0.0: + raise RuntimeError( + "Рассчитан неположительный payload bitrate " + f"для профиля {profile.profile_name}." + ) + + reduction_ratio = ( + source_file_bitrate_kbps + / payload_bitrate_kbps + ) + percent_of_source = ( + payload_bitrate_kbps + / source_file_bitrate_kbps + * 100.0 + ) + + results.append( + ProfileResult( + experiment=profile.experiment, + profile_name=profile.profile_name, + width=profile.width, + height=profile.height, + target_fps=profile.target_fps, + actual_fps=actual_fps, + color_mode=profile.color_mode, + jpeg_quality=profile.jpeg_quality, + source_duration_s=source_duration_seconds, + selected_frames=selected_frames, + total_bytes=total_bytes, + mean_frame_bytes=float(np.mean(frame_sizes)), + median_frame_bytes=float(np.median(frame_sizes)), + p95_frame_bytes=float( + np.percentile(frame_sizes, 95) + ), + max_frame_bytes=int(np.max(frame_sizes)), + payload_bitrate_bps=payload_bitrate_bps, + payload_bitrate_kbps=payload_bitrate_kbps, + source_file_bitrate_kbps=( + source_file_bitrate_kbps + ), + reduction_ratio=reduction_ratio, + percent_of_source=percent_of_source, + mean_psnr_db=float(np.mean(psnr_values)), + ) + ) + + if not results: + raise RuntimeError("Не создан ни один результат Lab026.") + + return results + + +def save_results_csv( + results: list[ProfileResult], +) -> None: + """ + Сохраняет все измеренные профили в UTF-8 CSV с запятой. + """ + + with PROFILES_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( + { + "experiment": result.experiment, + "profile_name": result.profile_name, + "width": result.width, + "height": result.height, + "target_fps": f"{result.target_fps:.6f}", + "actual_fps": f"{result.actual_fps:.6f}", + "color_mode": result.color_mode, + "jpeg_quality": result.jpeg_quality, + "source_duration_s": ( + f"{result.source_duration_s:.6f}" + ), + "selected_frames": result.selected_frames, + "total_bytes": result.total_bytes, + "mean_frame_bytes": ( + f"{result.mean_frame_bytes:.3f}" + ), + "median_frame_bytes": ( + f"{result.median_frame_bytes:.3f}" + ), + "p95_frame_bytes": ( + f"{result.p95_frame_bytes:.3f}" + ), + "max_frame_bytes": result.max_frame_bytes, + "payload_bitrate_bps": ( + f"{result.payload_bitrate_bps:.3f}" + ), + "payload_bitrate_kbps": ( + f"{result.payload_bitrate_kbps:.6f}" + ), + "source_file_bitrate_kbps": ( + f"{result.source_file_bitrate_kbps:.6f}" + ), + "reduction_ratio": ( + f"{result.reduction_ratio:.6f}" + ), + "percent_of_source": ( + f"{result.percent_of_source:.6f}" + ), + "mean_psnr_db": ( + f"{result.mean_psnr_db:.6f}" + ), + } + ) + + +def annotate_points( + axes: plt.Axes, + x_values: list[float], + y_values: list[float], +) -> None: + """ + Подписывает на графике значения payload bitrate. + """ + + for x_value, y_value in zip(x_values, y_values): + axes.annotate( + f"{y_value:.1f}", + (x_value, y_value), + xytext=(0, 7), + textcoords="offset points", + ha="center", + fontsize=8, + ) + + +def save_fps_plot( + results: list[ProfileResult], +) -> None: + """ + Строит зависимость payload bitrate от целевого FPS. + """ + + fps_results = sorted( + ( + result + for result in results + if result.experiment == EXPERIMENT_FPS_SWEEP + ), + key=lambda result: result.target_fps, + ) + + x_values = [result.target_fps for result in fps_results] + y_values = [ + result.payload_bitrate_kbps + for result in fps_results + ] + + figure, axes = plt.subplots(figsize=(10, 6)) + axes.plot(x_values, y_values, marker="o") + axes.set_xscale("log") + annotate_points(axes, x_values, y_values) + axes.set_title( + "Lab026. Влияние частоты кадров на JPEG payload bitrate" + ) + axes.set_xlabel("Целевая частота кадров, кадр/с") + axes.set_ylabel("Полезный битрейт JPEG, кбит/с") + axes.grid(True) + figure.tight_layout() + figure.savefig(FPS_PLOT_PATH, dpi=160) + plt.close(figure) + + +def save_resolution_plot( + results: list[ProfileResult], +) -> None: + """ + Строит зависимость payload bitrate от числа пикселей кадра. + """ + + resolution_results = sorted( + ( + result + for result in results + if result.experiment + == EXPERIMENT_RESOLUTION_SWEEP + ), + key=lambda result: result.width * result.height, + ) + + x_positions = list(range(len(resolution_results))) + y_values = [ + result.payload_bitrate_kbps + for result in resolution_results + ] + labels = [ + f"{result.width}×{result.height}" + for result in resolution_results + ] + + figure, axes = plt.subplots(figsize=(11, 6)) + axes.plot(x_positions, y_values, marker="o") + annotate_points(axes, x_positions, y_values) + axes.set_xticks(x_positions, labels) + axes.set_title( + "Lab026. Влияние разрешения на JPEG payload bitrate" + ) + axes.set_xlabel("Разрешение кадра") + axes.set_ylabel("Полезный битрейт JPEG, кбит/с") + axes.grid(True) + figure.tight_layout() + figure.savefig(RESOLUTION_PLOT_PATH, dpi=160) + plt.close(figure) + + +def save_quality_plot( + results: list[ProfileResult], +) -> None: + """ + Строит зависимости bitrate от JPEG quality для цвета и серого. + """ + + figure, axes = plt.subplots(figsize=(10, 6)) + + for color_mode in [ + COLOR_MODE_COLOR, + COLOR_MODE_GRAYSCALE, + ]: + mode_results = sorted( + ( + result + for result in results + if ( + result.experiment + == EXPERIMENT_QUALITY_SWEEP + and result.color_mode == color_mode + ) + ), + key=lambda result: result.jpeg_quality, + ) + + x_values = [ + result.jpeg_quality + for result in mode_results + ] + y_values = [ + result.payload_bitrate_kbps + for result in mode_results + ] + + axes.plot( + x_values, + y_values, + marker="o", + label=color_mode, + ) + annotate_points(axes, x_values, y_values) + + axes.set_title( + "Lab026. Влияние JPEG quality на полезный битрейт" + ) + axes.set_xlabel("JPEG quality") + axes.set_ylabel("Полезный битрейт JPEG, кбит/с") + axes.grid(True) + axes.legend() + figure.tight_layout() + figure.savefig(QUALITY_PLOT_PATH, dpi=160) + plt.close(figure) + + +def read_representative_frame( + source_path: Path, + source_frame_count: int, +) -> np.ndarray: + """ + Читает один кадр приблизительно из середины исходного ролика. + """ + + capture = cv2.VideoCapture(str(source_path)) + + if not capture.isOpened(): + raise RuntimeError( + f"OpenCV не смог открыть видео: {source_path}" + ) + + middle_frame_index = source_frame_count // 2 + + try: + capture.set( + cv2.CAP_PROP_POS_FRAMES, + middle_frame_index, + ) + frame_read, frame = capture.read() + finally: + capture.release() + + if not frame_read or frame is None: + raise RuntimeError( + "Не удалось прочитать репрезентативный кадр " + f"с индексом {middle_frame_index}." + ) + + return frame + + +def save_candidate_comparison( + source_path: Path, + source_frame_count: int, + profiles: list[VideoProfile], + results: list[ProfileResult], +) -> None: + """ + Создаёт таблицу восстановленных кадров девяти кандидатов. + """ + + candidate_profiles = [ + profile + for profile in profiles + if profile.experiment == EXPERIMENT_CANDIDATES + ] + + if len(candidate_profiles) != 9: + raise RuntimeError( + "Для сравнения ожидалось ровно девять " + "candidate-профилей." + ) + + results_by_name = { + result.profile_name: result + for result in results + } + representative_frame = read_representative_frame( + source_path, + source_frame_count, + ) + + figure, axes_grid = plt.subplots( + 3, + 3, + figsize=(18, 13), + ) + axes = axes_grid.ravel() + + for axes_item, profile in zip( + axes, + candidate_profiles, + ): + prepared_frame = prepare_frame( + representative_frame, + profile, + ) + _, decoded_frame = encode_decode_jpeg( + prepared_frame, + profile, + ) + + if profile.color_mode == COLOR_MODE_GRAYSCALE: + axes_item.imshow( + decoded_frame, + cmap="gray", + vmin=0, + vmax=255, + ) + else: + axes_item.imshow( + cv2.cvtColor( + decoded_frame, + cv2.COLOR_BGR2RGB, + ) + ) + + result = results_by_name[profile.profile_name] + wrapped_name = textwrap.fill( + profile.profile_name, + width=34, + ) + axes_item.set_title( + f"{wrapped_name}\n" + f"{profile.width}×{profile.height}, " + f"{profile.target_fps:g} кадр/с, " + f"Q={profile.jpeg_quality}\n" + f"{result.payload_bitrate_kbps:.2f} кбит/с, " + f"PSNR={result.mean_psnr_db:.2f} дБ", + fontsize=9, + ) + axes_item.axis("off") + + figure.suptitle( + "Lab026. Сравнение candidate-профилей покадрового JPEG", + fontsize=15, + ) + figure.tight_layout() + figure.savefig(CANDIDATE_COMPARISON_PATH, dpi=160) + plt.close(figure) + + +def format_result_line(result: ProfileResult) -> str: + """ + Формирует компактную строку результата для текстового отчёта. + """ + + return ( + f"{result.profile_name}: " + f"{result.width}x{result.height}, " + f"{result.target_fps:g} кадр/с, " + f"{result.color_mode}, Q={result.jpeg_quality}, " + f"кадров={result.selected_frames}, " + f"payload={result.payload_bitrate_kbps:.3f} кбит/с, " + f"PSNR={result.mean_psnr_db:.3f} дБ, " + f"уменьшение={result.reduction_ratio:.2f}x" + ) + + +def append_experiment_section( + report_lines: list[str], + title: str, + experiment_name: str, + results: list[ProfileResult], +) -> None: + """ + Добавляет в отчёт все строки одного эксперимента. + """ + + report_lines.extend(["", title]) + + for result in results: + if result.experiment == experiment_name: + report_lines.append(format_result_line(result)) + + +def append_threshold_section( + report_lines: list[str], + threshold_kbps: float, + candidate_results: list[ProfileResult], +) -> None: + """ + Перечисляет candidate-профили не выше заданного битрейта. + """ + + report_lines.extend( + [ + "", + ( + "Candidate-профили с payload bitrate " + f"не более {threshold_kbps:g} кбит/с:" + ), + ] + ) + + matching_results = [ + result + for result in candidate_results + if result.payload_bitrate_kbps <= threshold_kbps + ] + + if not matching_results: + report_lines.append("Нет.") + return + + for result in matching_results: + report_lines.append(format_result_line(result)) + + +def write_report( + source_width: int, + source_height: int, + source_fps: float, + source_frame_count: int, + source_duration_seconds: float, + source_file_size_bytes: int, + source_file_bitrate_kbps: float, + results: list[ProfileResult], +) -> None: + """ + Создаёт полный текстовый отчёт Lab026 в кодировке UTF-8. + """ + + report_lines = [ + "Lab026. Исследование уменьшения видеопотока " + "для радиоуправления ровером", + "", + "Цель: исследовать уменьшение полезной нагрузки " + "видеоканала для передачи через ретранслятор.", + f"Исходный файл: {SOURCE_VIDEO_PATH}", + f"Размер исходного файла: {source_file_size_bytes} байт", + ( + f"Исходное разрешение: " + f"{source_width}x{source_height}" + ), + f"Исходный FPS: {source_fps:.6f}", + f"Число кадров: {source_frame_count}", + f"Длительность: {source_duration_seconds:.6f} с", + ( + "Приблизительный исходный файловый битрейт: " + f"{source_file_bitrate_kbps:.3f} кбит/с" + ), + "", + "Исследуется независимое покадровое JPEG-сжатие. " + "JPEG хранится и декодируется только в памяти.", + "Рассчитанный bitrate учитывает только JPEG payload.", + "Не учитываются заголовки радиопакетов, CRC, FEC, " + "преамбула, интервалы, повторы и команды управления.", + ] + + append_experiment_section( + report_lines, + "Результаты fps_sweep:", + EXPERIMENT_FPS_SWEEP, + results, + ) + append_experiment_section( + report_lines, + "Результаты resolution_sweep:", + EXPERIMENT_RESOLUTION_SWEEP, + results, + ) + append_experiment_section( + report_lines, + "Результаты quality_sweep:", + EXPERIMENT_QUALITY_SWEEP, + results, + ) + append_experiment_section( + report_lines, + "Результаты candidate_profiles:", + EXPERIMENT_CANDIDATES, + results, + ) + + candidate_results = [ + result + for result in results + if result.experiment == EXPERIMENT_CANDIDATES + ] + + for threshold_kbps in [100.0, 50.0, 20.0, 10.0]: + append_threshold_section( + report_lines, + threshold_kbps, + candidate_results, + ) + + minimum_bitrate_result = min( + results, + key=lambda result: result.payload_bitrate_kbps, + ) + + report_lines.extend( + [ + "", + "Профиль с минимальным измеренным битрейтом:", + format_result_line(minimum_bitrate_result), + "", + "Автоматические метрики не заменяют визуальную оценку " + "пригодности изображения для управления ровером.", + ( + "Сравнительное изображение: " + f"{CANDIDATE_COMPARISON_PATH}" + ), + "Следующий шаг: пользователь должен визуально выбрать " + "пригодные candidate-профили.", + ] + ) + + REPORT_PATH.write_text( + "\n".join(report_lines), + encoding="utf-8", + ) + + +def validate_output_files() -> None: + """ + Проверяет наличие и ненулевой размер шести результатов Lab026. + """ + + for output_path in EXPECTED_OUTPUT_PATHS: + if not output_path.is_file(): + raise RuntimeError( + f"Выходной файл не создан: {output_path}" + ) + + if output_path.stat().st_size <= 0: + raise RuntimeError( + f"Выходной файл имеет нулевой размер: {output_path}" + ) + + +def print_candidate_results( + results: list[ProfileResult], +) -> None: + """ + Выводит в консоль полные результаты девяти кандидатов. + """ + + print() + print("Результаты candidate_profiles:") + + for result in results: + if result.experiment == EXPERIMENT_CANDIDATES: + print(f" {format_result_line(result)}") + + +def main() -> None: + """ + Выполняет все четыре эксперимента Lab026. + """ + + OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True) + + print("Чтение метаданных исходного видео...") + + ( + source_width, + source_height, + source_fps, + source_frame_count, + source_duration_seconds, + source_file_size_bytes, + source_file_bitrate_kbps, + ) = read_video_metadata(SOURCE_VIDEO_PATH) + + print() + print("Параметры исходного видео:") + print(f" Путь: {SOURCE_VIDEO_PATH}") + print(f" Размер: {source_file_size_bytes} байт") + print( + f" Разрешение: " + f"{source_width}x{source_height}" + ) + print(f" FPS: {source_fps:.6f}") + print(f" Кадров: {source_frame_count}") + print(f" Длительность:{source_duration_seconds:.6f} с") + print( + f" Битрейт: " + f"{source_file_bitrate_kbps:.3f} кбит/с" + ) + + profiles = build_profiles() + + print() + print(f"Сформировано профилей: {len(profiles)}") + print("Последовательная обработка кадров...") + + results = process_profiles( + source_path=SOURCE_VIDEO_PATH, + profiles=profiles, + source_fps=source_fps, + source_frame_count=source_frame_count, + source_duration_seconds=source_duration_seconds, + source_file_bitrate_kbps=source_file_bitrate_kbps, + ) + + print() + print("Сохранение CSV...") + save_results_csv(results) + + print("Построение графика FPS...") + save_fps_plot(results) + + print("Построение графика разрешений...") + save_resolution_plot(results) + + print("Построение графика JPEG quality...") + save_quality_plot(results) + + print("Создание сравнительного изображения кандидатов...") + save_candidate_comparison( + source_path=SOURCE_VIDEO_PATH, + source_frame_count=source_frame_count, + profiles=profiles, + results=results, + ) + + print("Создание текстового отчёта...") + write_report( + 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, + source_file_bitrate_kbps=source_file_bitrate_kbps, + results=results, + ) + + validate_output_files() + print_candidate_results(results) + + print() + print("Созданы файлы:") + + for output_path in EXPECTED_OUTPUT_PATHS: + print( + f" {output_path} " + f"({output_path.stat().st_size} байт)" + ) + + print() + print("Lab026 выполнена успешно.") + print( + "Для ручной оценки откройте: " + f"{CANDIDATE_COMPARISON_PATH}" + ) + + +if __name__ == "__main__": + main() diff --git a/tests/lab026b_four_bit_video_benchmark.py b/tests/lab026b_four_bit_video_benchmark.py new file mode 100644 index 0000000..435fa2d --- /dev/null +++ b/tests/lab026b_four_bit_video_benchmark.py @@ -0,0 +1,1861 @@ +""" +Lab026B. Увеличение разрешения за счёт глубины цвета 4 бита. + +Исследуются независимые кадры с 4-битной яркостью и 4-битным +индексированным цветом. Под индексированным цветом понимается один +4-битный индекс на пиксель и палитра не более чем из 16 цветов. +RGB444, потоковые видеокодеки и межкадровое кодирование не применяются. + +Все промежуточные JPEG, PNG и zlib-представления находятся только +в оперативной памяти. Исходное видео открывается только для чтения. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from io import BytesIO +from pathlib import Path +import csv +import math +import textwrap +import zlib + +import cv2 +import matplotlib +import numpy as np +from PIL import Image + + +matplotlib.use("Agg") +import matplotlib.pyplot as plt + + +# --------------------------------------------------------------------- +# Пути +# --------------------------------------------------------------------- + +SOURCE_VIDEO_PATH = Path("data/raw/lab026_rover_source.mp4") + +OUTPUT_DIRECTORY = Path("data/processed/lab026b") +CSV_PATH = OUTPUT_DIRECTORY / "lab026b_profiles.csv" +REPORT_PATH = OUTPUT_DIRECTORY / "lab026b_report.txt" +RESOLUTION_PLOT_PATH = ( + OUTPUT_DIRECTORY / "lab026b_bitrate_vs_resolution.png" +) +CODEC_COMPARISON_PATH = ( + OUTPUT_DIRECTORY / "lab026b_codec_comparison.png" +) +EQUAL_BITRATE_COMPARISON_PATH = ( + OUTPUT_DIRECTORY / "lab026b_equal_bitrate_candidates.png" +) + +EXPECTED_OUTPUT_PATHS = [ + CSV_PATH, + REPORT_PATH, + RESOLUTION_PLOT_PATH, + CODEC_COMPARISON_PATH, + EQUAL_BITRATE_COMPARISON_PATH, +] + + +# --------------------------------------------------------------------- +# Параметры эксперимента +# --------------------------------------------------------------------- + +EXPERIMENT_NAME = "four_bit_resolution_sweep" + +TARGET_FPS = 2.0 +BASELINE_BITRATE_KBPS = 80.283660 +FRAME_TIME_EPSILON_SECONDS = 1e-9 + +FRAME_HEADER_BYTES = 16 +FIXED_PALETTE_BYTES = 16 * 3 +MAXIMUM_PSNR_DB = 100.0 + +RESOLUTIONS = [ + (320, 180), + (400, 225), + (448, 252), + (480, 270), + (560, 315), + (640, 360), +] + +MODE_GRAY8_JPEG = "gray8_jpeg" +MODE_GRAY4_JPEG = "gray4_jpeg" +MODE_GRAY4_PACKED_ZLIB = "gray4_packed_zlib" +MODE_COLOR16_PNG = "color16_png" +MODE_COLOR16_PACKED_ZLIB = "color16_packed_zlib" + +PROFILE_MODES = [ + MODE_GRAY8_JPEG, + MODE_GRAY4_JPEG, + MODE_GRAY4_PACKED_ZLIB, + MODE_COLOR16_PNG, + MODE_COLOR16_PACKED_ZLIB, +] + +CSV_FIELD_NAMES = [ + "experiment", + "profile_name", + "mode", + "width", + "height", + "pixel_count", + "target_fps", + "actual_fps", + "bits_per_pixel_before_compression", + "maximum_gray_levels", + "maximum_colors", + "selected_frames", + "total_payload_bytes", + "mean_frame_bytes", + "median_frame_bytes", + "p95_frame_bytes", + "max_frame_bytes", + "payload_bitrate_bps", + "payload_bitrate_kbps", + "baseline_bitrate_kbps", + "difference_from_baseline_kbps", + "percent_of_baseline_bitrate", + "compression_ratio_from_unpacked_source", + "mean_psnr_db", + "header_bytes_per_frame", + "palette_bytes_per_frame", + "notes", +] + + +@dataclass(frozen=True) +class FourBitProfile: + """ + Описывает разрешение и режим одного профиля Lab026B. + """ + + experiment: str + profile_name: str + mode: str + width: int + height: int + target_fps: float + bits_per_pixel_before_compression: int + maximum_gray_levels: int + maximum_colors: int + header_bytes_per_frame: int + palette_bytes_per_frame: int + notes: str + + +@dataclass(frozen=True) +class FourBitResult: + """ + Содержит измеренные характеристики одного профиля Lab026B. + """ + + experiment: str + profile_name: str + mode: str + width: int + height: int + pixel_count: int + target_fps: float + actual_fps: float + bits_per_pixel_before_compression: int + maximum_gray_levels: int + maximum_colors: int + selected_frames: int + total_payload_bytes: int + mean_frame_bytes: float + median_frame_bytes: float + p95_frame_bytes: float + max_frame_bytes: int + payload_bitrate_bps: float + payload_bitrate_kbps: float + baseline_bitrate_kbps: float + difference_from_baseline_kbps: float + percent_of_baseline_bitrate: float + compression_ratio_from_unpacked_source: float + mean_psnr_db: float + header_bytes_per_frame: int + palette_bytes_per_frame: int + notes: str + + +@dataclass +class ProfileAccumulator: + """ + Накапливает размеры кадров и PSNR при чтении исходного ролика. + """ + + next_sample_time: float + payload_sizes: list[int] + psnr_values: list[float] + maximum_observed_levels: int + maximum_observed_colors: int + + +@dataclass(frozen=True) +class FrameEncodingResult: + """ + Описывает результат кодирования одного кадра в памяти. + """ + + payload_size_bytes: int + restored_frame: np.ndarray + observed_gray_levels: int + observed_colors: int + + +def read_video_metadata( + source_path: Path, +) -> tuple[int, int, float, int, float, int]: + """ + Читает параметры исходного видео через OpenCV. + """ + + if not source_path.is_file(): + 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("Получено некорректное разрешение видео.") + + if source_fps <= 0.0: + raise RuntimeError("FPS исходного видео равен нулю.") + + if frame_count <= 0: + raise RuntimeError("Число кадров исходного видео равно нулю.") + + duration_seconds = frame_count / source_fps + source_size_bytes = source_path.stat().st_size + + if source_size_bytes <= 0: + raise RuntimeError("Исходное видео имеет нулевой размер.") + + return ( + width, + height, + source_fps, + frame_count, + duration_seconds, + source_size_bytes, + ) + + +def build_profiles() -> list[FourBitProfile]: + """ + Формирует 30 профилей: шесть разрешений и пять режимов. + """ + + profiles: list[FourBitProfile] = [] + + mode_parameters = { + MODE_GRAY8_JPEG: { + "bits": 8, + "gray_levels": 256, + "colors": 0, + "header": 0, + "palette": 0, + "notes": "Grayscale 8 bit, JPEG quality 40.", + }, + MODE_GRAY4_JPEG: { + "bits": 4, + "gray_levels": 16, + "colors": 0, + "header": 0, + "palette": 0, + "notes": ( + "Grayscale quantized to 16 levels, " + "restored to uint8, JPEG quality 40." + ), + }, + MODE_GRAY4_PACKED_ZLIB: { + "bits": 4, + "gray_levels": 16, + "colors": 0, + "header": FRAME_HEADER_BYTES, + "palette": 0, + "notes": ( + "Two 4-bit grayscale indices per byte, " + "zlib level 9, 16-byte frame header." + ), + }, + MODE_COLOR16_PNG: { + "bits": 4, + "gray_levels": 0, + "colors": 16, + "header": 0, + "palette": 0, + "notes": ( + "Pillow palette image, maximum 16 colors, " + "no dithering, optimized indexed PNG." + ), + }, + MODE_COLOR16_PACKED_ZLIB: { + "bits": 4, + "gray_levels": 0, + "colors": 16, + "header": FRAME_HEADER_BYTES, + "palette": FIXED_PALETTE_BYTES, + "notes": ( + "Two 4-bit palette indices per byte, " + "zlib level 9, 16-byte header and " + "fixed 48-byte palette." + ), + }, + } + + for width, height in RESOLUTIONS: + for mode in PROFILE_MODES: + parameters = mode_parameters[mode] + profiles.append( + FourBitProfile( + experiment=EXPERIMENT_NAME, + profile_name=( + f"{width}x{height}_{mode}_2fps" + ), + mode=mode, + width=width, + height=height, + target_fps=TARGET_FPS, + bits_per_pixel_before_compression=( + parameters["bits"] + ), + maximum_gray_levels=( + parameters["gray_levels"] + ), + maximum_colors=parameters["colors"], + header_bytes_per_frame=parameters["header"], + palette_bytes_per_frame=parameters["palette"], + notes=parameters["notes"], + ) + ) + + return profiles + + +def should_sample_frame( + frame_time_seconds: float, + next_sample_time_seconds: float, + epsilon_seconds: float = FRAME_TIME_EPSILON_SECONDS, +) -> bool: + """ + Выбирает кадры по временной шкале, как в основной Lab026. + """ + + return ( + frame_time_seconds + epsilon_seconds + >= next_sample_time_seconds + ) + + +def quantize_gray4( + gray_frame: np.ndarray, +) -> tuple[np.ndarray, np.ndarray]: + """ + Квантует яркость в индексы 0...15 и восстанавливает uint8. + """ + + indices = np.rint( + gray_frame.astype(np.float64) + / 255.0 + * 15.0 + ).astype(np.uint8) + + if int(np.min(indices)) < 0 or int(np.max(indices)) > 15: + raise RuntimeError("Индексы gray4 вышли из диапазона 0...15.") + + if np.unique(indices).size > 16: + raise RuntimeError("Gray4 содержит более 16 уровней.") + + restored = np.rint( + indices.astype(np.float64) + / 15.0 + * 255.0 + ).astype(np.uint8) + + return indices, restored + + +def pack_nibbles(indices: np.ndarray) -> bytes: + """ + Упаковывает два 4-битных индекса в один байт. + + Первый пиксель помещается в старшие четыре бита. При нечётном + количестве пикселей младший полубайт последнего байта равен нулю. + """ + + flattened = np.asarray( + indices, + dtype=np.uint8, + ).reshape(-1) + + if flattened.size == 0: + raise RuntimeError("Нельзя упаковать пустой массив индексов.") + + if int(np.min(flattened)) < 0 or int(np.max(flattened)) > 15: + raise RuntimeError( + "Перед упаковкой обнаружен индекс вне диапазона 0...15." + ) + + if flattened.size % 2 != 0: + flattened = np.pad( + flattened, + (0, 1), + mode="constant", + constant_values=0, + ) + + packed = ( + (flattened[0::2] << 4) + | flattened[1::2] + ).astype(np.uint8) + + return packed.tobytes() + + +def unpack_nibbles( + packed_bytes: bytes, + original_pixel_count: int, +) -> np.ndarray: + """ + Восстанавливает исходную последовательность 4-битных индексов. + """ + + if original_pixel_count <= 0: + raise RuntimeError( + "Число восстанавливаемых пикселей должно быть положительным." + ) + + packed = np.frombuffer( + packed_bytes, + dtype=np.uint8, + ) + + unpacked = np.empty( + packed.size * 2, + dtype=np.uint8, + ) + unpacked[0::2] = packed >> 4 + unpacked[1::2] = packed & 0x0F + + if original_pixel_count > unpacked.size: + raise RuntimeError( + "Упакованных данных недостаточно для восстановления." + ) + + return unpacked[:original_pixel_count].copy() + + +def pillow_quantize_parameters() -> tuple[object, object]: + """ + Возвращает совместимые enum Pillow для Median Cut без дизеринга. + """ + + quantize_enum = getattr(Image, "Quantize", None) + dither_enum = getattr(Image, "Dither", None) + + method = ( + quantize_enum.MEDIANCUT + if quantize_enum is not None + else Image.MEDIANCUT + ) + dither = ( + dither_enum.NONE + if dither_enum is not None + else Image.NONE + ) + + return method, dither + + +def quantize_color16( + rgb_frame: np.ndarray, +) -> tuple[Image.Image, np.ndarray, np.ndarray, np.ndarray]: + """ + Создаёт палитровое Pillow-изображение с максимум 16 цветами. + + Возвращает изображение P, индексы, палитру 16x3 и восстановленный + RGB-кадр. + """ + + method, dither = pillow_quantize_parameters() + source_image = Image.fromarray(rgb_frame, mode="RGB") + palette_image = source_image.quantize( + colors=16, + method=method, + dither=dither, + ) + + if palette_image.mode != "P": + raise RuntimeError( + "Pillow вернул не палитровое изображение режима P." + ) + + indices = np.asarray( + palette_image, + dtype=np.uint8, + ) + + if int(np.min(indices)) < 0 or int(np.max(indices)) > 15: + raise RuntimeError( + "Индексы color16 вышли из диапазона 0...15." + ) + + if np.unique(indices).size > 16: + raise RuntimeError("Color16 содержит более 16 цветов.") + + raw_palette = palette_image.getpalette() + + if raw_palette is None: + raise RuntimeError("Pillow не вернул палитру изображения.") + + palette_values = list(raw_palette[:FIXED_PALETTE_BYTES]) + + if len(palette_values) < FIXED_PALETTE_BYTES: + palette_values.extend( + [0] * (FIXED_PALETTE_BYTES - len(palette_values)) + ) + + palette_rgb = np.asarray( + palette_values, + dtype=np.uint8, + ).reshape(16, 3) + restored_rgb = np.asarray( + palette_image.convert("RGB"), + dtype=np.uint8, + ).copy() + + return ( + palette_image, + indices, + palette_rgb, + restored_rgb, + ) + + +def encode_gray_jpeg( + gray_frame: np.ndarray, + jpeg_quality: int = 40, +) -> tuple[int, np.ndarray]: + """ + Кодирует grayscale JPEG в памяти и возвращает декодированный кадр. + """ + + encoding_succeeded, encoded_jpeg = cv2.imencode( + ".jpg", + gray_frame, + [cv2.IMWRITE_JPEG_QUALITY, jpeg_quality], + ) + + if not encoding_succeeded or encoded_jpeg is None: + raise RuntimeError("JPEG encoding завершился ошибкой.") + + decoded_gray = cv2.imdecode( + encoded_jpeg, + cv2.IMREAD_GRAYSCALE, + ) + + if decoded_gray is None: + raise RuntimeError("JPEG decoding завершился ошибкой.") + + if decoded_gray.shape != gray_frame.shape: + raise RuntimeError( + "Размер JPEG после декодирования не совпадает с эталоном." + ) + + return int(encoded_jpeg.size), decoded_gray + + +def encode_palette_png( + palette_image: Image.Image, +) -> tuple[int, np.ndarray]: + """ + Сохраняет палитровый PNG в BytesIO и декодирует его обратно в RGB. + """ + + if palette_image.mode != "P": + raise RuntimeError("Для PNG ожидался палитровый режим P.") + + buffer = BytesIO() + palette_image.save( + buffer, + format="PNG", + optimize=True, + ) + png_bytes = buffer.getvalue() + + if not png_bytes: + raise RuntimeError("Pillow создал пустой PNG payload.") + + with Image.open(BytesIO(png_bytes)) as decoded_image: + if decoded_image.mode != "P": + raise RuntimeError( + "Сохранённый PNG перестал быть палитровым." + ) + + decoded_rgb = np.asarray( + decoded_image.convert("RGB"), + dtype=np.uint8, + ).copy() + + return len(png_bytes), decoded_rgb + + +def encode_packed_zlib( + indices: np.ndarray, + header_bytes: int, + palette_bytes: int, +) -> tuple[int, np.ndarray]: + """ + Упаковывает индексы по два на байт и сжимает zlib level 9. + + Функция обязательно проверяет точное восстановление pack/unpack. + """ + + original_shape = indices.shape + original_count = indices.size + packed_bytes = pack_nibbles(indices) + unpacked_flat = unpack_nibbles( + packed_bytes, + original_count, + ) + + original_flat = np.asarray( + indices, + dtype=np.uint8, + ).reshape(-1) + + if not np.array_equal(unpacked_flat, original_flat): + raise RuntimeError("Проверка pack/unpack завершилась ошибкой.") + + compressed_payload = zlib.compress( + packed_bytes, + level=9, + ) + + if not compressed_payload: + raise RuntimeError("zlib создал пустой payload.") + + total_payload_bytes = ( + header_bytes + + palette_bytes + + len(compressed_payload) + ) + unpacked_indices = unpacked_flat.reshape(original_shape) + + return total_payload_bytes, unpacked_indices + + +def calculate_psnr( + reference_frame: np.ndarray, + restored_frame: np.ndarray, +) -> float: + """ + Рассчитывает PSNR в float64 и ограничивает идеальный случай 100 дБ. + """ + + if reference_frame.shape != restored_frame.shape: + raise RuntimeError( + "PSNR нельзя рассчитать для кадров разных размеров." + ) + + difference = ( + reference_frame.astype(np.float64) + - restored_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 process_frame_for_profile( + source_frame: np.ndarray, + profile: FourBitProfile, +) -> FrameEncodingResult: + """ + Применяет один из пяти фиксированных режимов к исходному кадру. + """ + + resized_bgr = cv2.resize( + source_frame, + (profile.width, profile.height), + interpolation=cv2.INTER_AREA, + ) + + if profile.mode == MODE_GRAY8_JPEG: + reference_gray = cv2.cvtColor( + resized_bgr, + cv2.COLOR_BGR2GRAY, + ) + payload_size, restored_gray = encode_gray_jpeg( + reference_gray, + jpeg_quality=40, + ) + return FrameEncodingResult( + payload_size, + restored_gray, + int(np.unique(reference_gray).size), + 0, + ) + + if profile.mode in { + MODE_GRAY4_JPEG, + MODE_GRAY4_PACKED_ZLIB, + }: + reference_gray = cv2.cvtColor( + resized_bgr, + cv2.COLOR_BGR2GRAY, + ) + indices, quantized_gray = quantize_gray4( + reference_gray + ) + observed_levels = int(np.unique(indices).size) + + if profile.mode == MODE_GRAY4_JPEG: + payload_size, restored_gray = encode_gray_jpeg( + quantized_gray, + jpeg_quality=40, + ) + else: + payload_size, unpacked_indices = ( + encode_packed_zlib( + indices, + FRAME_HEADER_BYTES, + 0, + ) + ) + restored_gray = np.rint( + unpacked_indices.astype(np.float64) + / 15.0 + * 255.0 + ).astype(np.uint8) + + return FrameEncodingResult( + payload_size, + restored_gray, + observed_levels, + 0, + ) + + if profile.mode in { + MODE_COLOR16_PNG, + MODE_COLOR16_PACKED_ZLIB, + }: + reference_rgb = cv2.cvtColor( + resized_bgr, + cv2.COLOR_BGR2RGB, + ) + ( + palette_image, + indices, + palette_rgb, + quantized_rgb, + ) = quantize_color16(reference_rgb) + observed_colors = int(np.unique(indices).size) + + if profile.mode == MODE_COLOR16_PNG: + payload_size, restored_rgb = encode_palette_png( + palette_image + ) + else: + payload_size, unpacked_indices = ( + encode_packed_zlib( + indices, + FRAME_HEADER_BYTES, + FIXED_PALETTE_BYTES, + ) + ) + restored_rgb = palette_rgb[unpacked_indices] + + return FrameEncodingResult( + payload_size, + restored_rgb, + 0, + observed_colors, + ) + + raise RuntimeError(f"Неизвестный режим: {profile.mode}") + + +def reference_frame_for_psnr( + source_frame: np.ndarray, + profile: FourBitProfile, +) -> np.ndarray: + """ + Создаёт эталон исходного resized-кадра для PSNR. + """ + + resized_bgr = cv2.resize( + source_frame, + (profile.width, profile.height), + interpolation=cv2.INTER_AREA, + ) + + if profile.mode.startswith("gray"): + return cv2.cvtColor( + resized_bgr, + cv2.COLOR_BGR2GRAY, + ) + + return cv2.cvtColor( + resized_bgr, + cv2.COLOR_BGR2RGB, + ) + + +def unpacked_reference_bytes( + profile: FourBitProfile, +) -> int: + """ + Возвращает размер эталонного массива до сжатия. + + Для grayscale это один uint8 на пиксель, для RGB — три uint8. + """ + + channel_count = ( + 1 + if profile.mode.startswith("gray") + else 3 + ) + return profile.width * profile.height * channel_count + + +def process_profiles( + source_path: Path, + profiles: list[FourBitProfile], + source_fps: float, + source_frame_count: int, + source_duration_seconds: float, +) -> list[FourBitResult]: + """ + За один последовательный проход измеряет все 30 профилей. + """ + + if len(profiles) != 30: + raise RuntimeError( + f"Ожидалось 30 профилей, получено {len(profiles)}." + ) + + accumulators = { + profile.profile_name: ProfileAccumulator( + next_sample_time=0.0, + payload_sizes=[], + psnr_values=[], + maximum_observed_levels=0, + maximum_observed_colors=0, + ) + for profile in profiles + } + + if len(accumulators) != len(profiles): + raise RuntimeError("Имена профилей должны быть уникальными.") + + 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}." + ) + + frame_time_seconds = frame_index / source_fps + + for profile in profiles: + accumulator = accumulators[profile.profile_name] + + if not should_sample_frame( + frame_time_seconds, + accumulator.next_sample_time, + ): + continue + + reference_frame = reference_frame_for_psnr( + source_frame, + profile, + ) + frame_result = process_frame_for_profile( + source_frame, + profile, + ) + psnr_db = calculate_psnr( + reference_frame, + frame_result.restored_frame, + ) + + accumulator.payload_sizes.append( + frame_result.payload_size_bytes + ) + accumulator.psnr_values.append(psnr_db) + accumulator.maximum_observed_levels = max( + accumulator.maximum_observed_levels, + frame_result.observed_gray_levels, + ) + accumulator.maximum_observed_colors = max( + accumulator.maximum_observed_colors, + frame_result.observed_colors, + ) + accumulator.next_sample_time += ( + 1.0 / profile.target_fps + ) + + 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[FourBitResult] = [] + + for profile in profiles: + accumulator = accumulators[profile.profile_name] + + if not accumulator.payload_sizes: + raise RuntimeError( + f"Нет выбранных кадров: {profile.profile_name}" + ) + + payload_sizes = np.asarray( + accumulator.payload_sizes, + dtype=np.float64, + ) + psnr_values = np.asarray( + accumulator.psnr_values, + dtype=np.float64, + ) + selected_frames = len(accumulator.payload_sizes) + total_payload_bytes = int( + sum(accumulator.payload_sizes) + ) + actual_fps = ( + selected_frames / source_duration_seconds + ) + payload_bitrate_bps = ( + total_payload_bytes + * 8.0 + / source_duration_seconds + ) + payload_bitrate_kbps = payload_bitrate_bps / 1000.0 + difference_from_baseline_kbps = ( + payload_bitrate_kbps + - BASELINE_BITRATE_KBPS + ) + percent_of_baseline = ( + payload_bitrate_kbps + / BASELINE_BITRATE_KBPS + * 100.0 + ) + compression_ratio = ( + unpacked_reference_bytes(profile) + / float(np.mean(payload_sizes)) + ) + + if profile.maximum_gray_levels == 16: + if accumulator.maximum_observed_levels > 16: + raise RuntimeError( + f"Gray4 превысил 16 уровней: {profile.profile_name}" + ) + + if profile.maximum_colors == 16: + if accumulator.maximum_observed_colors > 16: + raise RuntimeError( + f"Color16 превысил 16 цветов: {profile.profile_name}" + ) + + results.append( + FourBitResult( + experiment=profile.experiment, + profile_name=profile.profile_name, + mode=profile.mode, + width=profile.width, + height=profile.height, + pixel_count=profile.width * profile.height, + target_fps=profile.target_fps, + actual_fps=actual_fps, + bits_per_pixel_before_compression=( + profile.bits_per_pixel_before_compression + ), + maximum_gray_levels=( + profile.maximum_gray_levels + ), + maximum_colors=profile.maximum_colors, + selected_frames=selected_frames, + total_payload_bytes=total_payload_bytes, + mean_frame_bytes=float(np.mean(payload_sizes)), + median_frame_bytes=float(np.median(payload_sizes)), + p95_frame_bytes=float( + np.percentile(payload_sizes, 95) + ), + max_frame_bytes=int(np.max(payload_sizes)), + payload_bitrate_bps=payload_bitrate_bps, + payload_bitrate_kbps=payload_bitrate_kbps, + baseline_bitrate_kbps=BASELINE_BITRATE_KBPS, + difference_from_baseline_kbps=( + difference_from_baseline_kbps + ), + percent_of_baseline_bitrate=( + percent_of_baseline + ), + compression_ratio_from_unpacked_source=( + compression_ratio + ), + mean_psnr_db=float(np.mean(psnr_values)), + header_bytes_per_frame=( + profile.header_bytes_per_frame + ), + palette_bytes_per_frame=( + profile.palette_bytes_per_frame + ), + notes=profile.notes, + ) + ) + + return results + + +def save_csv(results: list[FourBitResult]) -> None: + """ + Сохраняет 30 результатов Lab026B в 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( + { + "experiment": result.experiment, + "profile_name": result.profile_name, + "mode": result.mode, + "width": result.width, + "height": result.height, + "pixel_count": result.pixel_count, + "target_fps": f"{result.target_fps:.6f}", + "actual_fps": f"{result.actual_fps:.6f}", + "bits_per_pixel_before_compression": ( + result.bits_per_pixel_before_compression + ), + "maximum_gray_levels": ( + result.maximum_gray_levels + ), + "maximum_colors": result.maximum_colors, + "selected_frames": result.selected_frames, + "total_payload_bytes": ( + result.total_payload_bytes + ), + "mean_frame_bytes": ( + f"{result.mean_frame_bytes:.3f}" + ), + "median_frame_bytes": ( + f"{result.median_frame_bytes:.3f}" + ), + "p95_frame_bytes": ( + f"{result.p95_frame_bytes:.3f}" + ), + "max_frame_bytes": result.max_frame_bytes, + "payload_bitrate_bps": ( + f"{result.payload_bitrate_bps:.3f}" + ), + "payload_bitrate_kbps": ( + f"{result.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_bitrate": ( + f"{result.percent_of_baseline_bitrate:.6f}" + ), + "compression_ratio_from_unpacked_source": ( + f"{result.compression_ratio_from_unpacked_source:.6f}" + ), + "mean_psnr_db": ( + f"{result.mean_psnr_db:.6f}" + ), + "header_bytes_per_frame": ( + result.header_bytes_per_frame + ), + "palette_bytes_per_frame": ( + result.palette_bytes_per_frame + ), + "notes": result.notes, + } + ) + + +def find_candidates( + results: list[FourBitResult], +) -> dict[str, list[FourBitResult]]: + """ + Формирует заданные группы профилей по payload bitrate. + """ + + return { + "70_to_90": [ + result + for result in results + if 70.0 <= result.payload_bitrate_kbps <= 90.0 + ], + "up_to_baseline": [ + result + for result in results + if ( + result.payload_bitrate_kbps + <= BASELINE_BITRATE_KBPS + ) + ], + "up_to_100": [ + result + for result in results + if result.payload_bitrate_kbps <= 100.0 + ], + "color16_up_to_100": [ + result + for result in results + if ( + result.mode.startswith("color16") + and result.payload_bitrate_kbps <= 100.0 + ) + ], + "larger_than_320_up_to_baseline": [ + result + for result in results + if ( + result.pixel_count > 320 * 180 + and result.payload_bitrate_kbps + <= BASELINE_BITRATE_KBPS + ) + ], + } + + +def maximum_resolution_results( + results: list[FourBitResult], + bitrate_limit_kbps: float, +) -> list[FourBitResult]: + """ + Возвращает все профили максимального разрешения до лимита. + """ + + matching = [ + result + for result in results + if result.payload_bitrate_kbps <= bitrate_limit_kbps + ] + + if not matching: + return [] + + maximum_pixel_count = max( + result.pixel_count + for result in matching + ) + + return [ + result + for result in matching + if result.pixel_count == maximum_pixel_count + ] + + +def annotate_bitrate_points( + axes: plt.Axes, + x_values: list[int], + y_values: list[float], +) -> None: + """ + Подписывает значения bitrate над точками. + """ + + for x_value, y_value in zip(x_values, y_values): + axes.annotate( + f"{y_value:.1f}", + (x_value, y_value), + xytext=(0, 6), + textcoords="offset points", + ha="center", + fontsize=7, + ) + + +def save_resolution_plot( + results: list[FourBitResult], +) -> None: + """ + Строит пять серий bitrate по разрешению и линию baseline. + """ + + figure, axes = plt.subplots(figsize=(13, 7)) + x_positions = list(range(len(RESOLUTIONS))) + resolution_labels = [ + f"{width}×{height}" + for width, height in RESOLUTIONS + ] + + for mode in PROFILE_MODES: + mode_results = sorted( + ( + result + for result in results + if result.mode == mode + ), + key=lambda result: result.pixel_count, + ) + y_values = [ + result.payload_bitrate_kbps + for result in mode_results + ] + axes.plot( + x_positions, + y_values, + marker="o", + label=mode, + ) + annotate_bitrate_points( + axes, + x_positions, + y_values, + ) + + axes.axhline( + BASELINE_BITRATE_KBPS, + linestyle="--", + label=( + f"Baseline {BASELINE_BITRATE_KBPS:.6f} кбит/с" + ), + ) + axes.set_xticks(x_positions, resolution_labels) + axes.set_title( + "Lab026B. Битрейт 4-битных режимов по разрешению" + ) + axes.set_xlabel("Разрешение кадра") + axes.set_ylabel("JPEG/PNG/zlib payload, кбит/с") + axes.grid(True) + axes.legend() + figure.tight_layout() + figure.savefig(RESOLUTION_PLOT_PATH, dpi=160) + plt.close(figure) + + +def save_codec_comparison( + results: list[FourBitResult], +) -> None: + """ + Строит столбчатое сравнение пяти режимов при 480x270. + """ + + selected_results = [ + result + for result in results + if result.width == 480 and result.height == 270 + ] + + if len(selected_results) != 5: + raise RuntimeError( + "Для 480x270 ожидалось пять результатов." + ) + + labels = [result.mode for result in selected_results] + bitrates = [ + result.payload_bitrate_kbps + for result in selected_results + ] + + figure, axes = plt.subplots(figsize=(12, 7)) + bars = axes.bar(labels, bitrates) + + for bar, result in zip(bars, selected_results): + axes.annotate( + ( + f"{result.payload_bitrate_kbps:.1f} кбит/с\n" + f"PSNR {result.mean_psnr_db:.1f} дБ" + ), + ( + bar.get_x() + bar.get_width() / 2.0, + bar.get_height(), + ), + xytext=(0, 5), + textcoords="offset points", + ha="center", + fontsize=8, + ) + + axes.axhline( + BASELINE_BITRATE_KBPS, + linestyle="--", + label=( + f"Baseline {BASELINE_BITRATE_KBPS:.6f} кбит/с" + ), + ) + axes.set_title("Lab026B. Сравнение режимов при 480×270") + axes.set_xlabel("Режим") + axes.set_ylabel("Payload bitrate, кбит/с") + axes.tick_params(axis="x", labelrotation=20) + axes.grid(True, axis="y") + axes.legend() + figure.tight_layout() + figure.savefig(CODEC_COMPARISON_PATH, dpi=160) + plt.close(figure) + + +def read_representative_frame( + source_path: Path, + source_frame_count: int, +) -> np.ndarray: + """ + Читает кадр приблизительно из середины исходного ролика. + """ + + capture = cv2.VideoCapture(str(source_path)) + + if not capture.isOpened(): + raise RuntimeError( + f"OpenCV не смог открыть видео: {source_path}" + ) + + middle_index = source_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( + f"Не удалось прочитать средний кадр {middle_index}." + ) + + return frame + + +def result_by_profile( + results: list[FourBitResult], + width: int, + height: int, + mode: str, +) -> FourBitResult: + """ + Находит единственный результат по разрешению и режиму. + """ + + matching = [ + result + for result in results + if ( + result.width == width + and result.height == height + and result.mode == mode + ) + ] + + if len(matching) != 1: + raise RuntimeError( + f"Ожидался один результат {width}x{height} {mode}." + ) + + return matching[0] + + +def save_comparison_image( + source_path: Path, + source_frame_count: int, + profiles: list[FourBitProfile], + results: list[FourBitResult], +) -> None: + """ + Создаёт таблицу девяти кадров при близких битрейтах. + """ + + result_lookup = { + ( + result.width, + result.height, + result.mode, + ): result + for result in results + } + profile_lookup = { + ( + profile.width, + profile.height, + profile.mode, + ): profile + for profile in profiles + } + + panel_keys: list[tuple[int, int, str, str]] = [ + (320, 180, MODE_GRAY8_JPEG, ""), + (448, 252, MODE_GRAY4_JPEG, ""), + (480, 270, MODE_GRAY4_JPEG, ""), + (448, 252, MODE_GRAY4_PACKED_ZLIB, ""), + (448, 252, MODE_COLOR16_PNG, ""), + (448, 252, MODE_COLOR16_PACKED_ZLIB, ""), + (480, 270, MODE_COLOR16_PNG, ""), + (480, 270, MODE_COLOR16_PACKED_ZLIB, ""), + ] + + best_candidates = [ + result + for result in results + if result.payload_bitrate_kbps <= 100.0 + ] + + if not best_candidates: + raise RuntimeError("Нет профиля с bitrate до 100 кбит/с.") + + best_result = max( + best_candidates, + key=lambda result: ( + result.pixel_count, + result.mean_psnr_db, + ), + ) + panel_keys.append( + ( + best_result.width, + best_result.height, + best_result.mode, + "BEST <=100 kbps", + ) + ) + + representative_frame = read_representative_frame( + source_path, + source_frame_count, + ) + + figure, axes_grid = plt.subplots( + 3, + 3, + figsize=(18, 13), + ) + + for axes, panel_key in zip( + axes_grid.ravel(), + panel_keys, + ): + width, height, mode, special_label = panel_key + profile = profile_lookup[(width, height, mode)] + result = result_lookup[(width, height, mode)] + frame_result = process_frame_for_profile( + representative_frame, + profile, + ) + displayed_frame = cv2.resize( + frame_result.restored_frame, + (640, 360), + interpolation=cv2.INTER_NEAREST, + ) + + if mode.startswith("gray"): + axes.imshow( + displayed_frame, + cmap="gray", + vmin=0, + vmax=255, + ) + level_text = ( + f"{profile.maximum_gray_levels} уровней" + ) + else: + axes.imshow(displayed_frame) + level_text = ( + f"{profile.maximum_colors} цветов" + ) + + title_prefix = ( + f"{special_label}\n" + if special_label + else "" + ) + axes.set_title( + title_prefix + + textwrap.fill(mode, width=28) + + "\n" + + f"{width}×{height}, " + + f"{result.payload_bitrate_kbps:.2f} кбит/с\n" + + f"PSNR {result.mean_psnr_db:.2f} дБ, " + + level_text, + fontsize=9, + ) + axes.axis("off") + + figure.suptitle( + "Lab026B. Сравнение 4-битных профилей", + fontsize=15, + ) + figure.tight_layout() + figure.savefig( + EQUAL_BITRATE_COMPARISON_PATH, + dpi=160, + ) + plt.close(figure) + + +def format_result_line(result: FourBitResult) -> str: + """ + Формирует полную строку результата для текстового отчёта. + """ + + return ( + f"{result.profile_name}: " + f"mode={result.mode}, " + f"{result.width}x{result.height}, " + f"frames={result.selected_frames}, " + f"actual_fps={result.actual_fps:.6f}, " + f"total={result.total_payload_bytes} bytes, " + f"mean={result.mean_frame_bytes:.3f}, " + f"median={result.median_frame_bytes:.3f}, " + f"p95={result.p95_frame_bytes:.3f}, " + f"max={result.max_frame_bytes}, " + f"bitrate={result.payload_bitrate_kbps:.6f} kbit/s, " + f"baseline_diff={result.difference_from_baseline_kbps:.6f}, " + f"baseline_percent={result.percent_of_baseline_bitrate:.3f}%, " + f"compression={result.compression_ratio_from_unpacked_source:.3f}x, " + f"PSNR={result.mean_psnr_db:.6f} dB" + ) + + +def append_result_group( + report_lines: list[str], + title: str, + results: list[FourBitResult], +) -> None: + """ + Добавляет в отчёт именованную группу результатов. + """ + + report_lines.extend(["", title]) + + if not results: + report_lines.append("Нет.") + return + + for result in results: + report_lines.append(format_result_line(result)) + + +def write_report( + source_width: int, + source_height: int, + source_fps: float, + source_frame_count: int, + source_duration_seconds: float, + source_size_bytes: int, + results: list[FourBitResult], +) -> None: + """ + Создаёт полный UTF-8 отчёт Lab026B. + """ + + candidates = find_candidates(results) + maximum_to_baseline = maximum_resolution_results( + results, + BASELINE_BITRATE_KBPS, + ) + maximum_to_100 = maximum_resolution_results( + results, + 100.0, + ) + + gray4_results = [ + result + for result in results + if result.mode.startswith("gray4") + ] + color16_results = [ + result + for result in results + if result.mode.startswith("color16") + ] + + report_lines = [ + "Lab026B. Увеличение разрешения кадра " + "за счёт глубины цвета 4 бита", + "", + "Цель: проверить увеличение разрешения при битрейте, " + "близком к контрольным 80.283660 кбит/с.", + f"Исходное видео: {SOURCE_VIDEO_PATH}", + f"Размер исходного видео: {source_size_bytes} байт", + f"Исходное разрешение: {source_width}×{source_height}", + f"Исходный FPS: {source_fps:.6f}", + f"Число кадров: {source_frame_count}", + f"Длительность: {source_duration_seconds:.6f} с", + "", + "4-битная яркость: индекс 0...15, то есть 16 уровней " + "серого на пиксель.", + "4-битный индексированный цвет: один индекс 0...15 " + "на пиксель и палитра максимум из 16 RGB-цветов.", + "Это не RGB444: RGB444 требует по 4 бита на каждый " + "из трёх каналов, то есть 12 бит на пиксель.", + "", + "Режимы:", + "gray8_jpeg — контрольный grayscale JPEG Q40.", + "gray4_jpeg — 16 уровней серого, восстановление uint8, " + "JPEG Q40.", + "gray4_packed_zlib — 4-битные индексы, два на байт, " + "zlib level 9 и заголовок 16 байт.", + "color16_png — палитра максимум 16 цветов, без дизеринга, " + "индексированный PNG в памяти.", + "color16_packed_zlib — 4-битные индексы, zlib level 9, " + "заголовок 16 байт и фиксированная палитра 48 байт.", + "", + f"Контрольный baseline: {BASELINE_BITRATE_KBPS:.6f} кбит/с.", + "CRC, FEC, радиозаголовки и пакетирование пока не учтены.", + "Статические метрики не учитывают задержку видеоканала.", + "", + "Полные результаты 30 профилей:", + ] + + for result in results: + report_lines.append(format_result_line(result)) + + append_result_group( + report_lines, + "Профили 70–90 кбит/с:", + candidates["70_to_90"], + ) + append_result_group( + report_lines, + "Профили до baseline:", + candidates["up_to_baseline"], + ) + append_result_group( + report_lines, + "Профили до 100 кбит/с:", + candidates["up_to_100"], + ) + append_result_group( + report_lines, + "Цветные color16-профили до 100 кбит/с:", + candidates["color16_up_to_100"], + ) + append_result_group( + report_lines, + ( + "Профили больше 320x180 и не выше baseline:" + ), + candidates["larger_than_320_up_to_baseline"], + ) + append_result_group( + report_lines, + "Максимальное разрешение до baseline:", + maximum_to_baseline, + ) + append_result_group( + report_lines, + "Максимальное разрешение до 100 кбит/с:", + maximum_to_100, + ) + + report_lines.extend( + [ + "", + "Сравнение gray4 и color16:", + ( + "Минимальный gray4 bitrate: " + f"{min(result.payload_bitrate_kbps for result in gray4_results):.6f} " + "кбит/с." + ), + ( + "Минимальный color16 bitrate: " + f"{min(result.payload_bitrate_kbps for result in color16_results):.6f} " + "кбит/с." + ), + "Размер палитры packed color16: 48 байт на кадр.", + "Размер компактного заголовка packed-режимов: " + "16 байт на кадр.", + "", + "Нельзя автоматически объявлять профиль безопасным " + "для управления ровером по одной статической метрике.", + ( + "Сравнительное изображение: " + f"{EQUAL_BITRATE_COMPARISON_PATH}" + ), + "Следующий шаг: ручная визуальная оценка пользователем.", + ] + ) + + REPORT_PATH.write_text( + "\n".join(report_lines), + encoding="utf-8", + ) + + +def validate_output_files() -> None: + """ + Проверяет наличие и ненулевой размер пяти результатов. + """ + + for path in EXPECTED_OUTPUT_PATHS: + if not path.is_file(): + raise RuntimeError(f"Выходной файл не создан: {path}") + + if path.stat().st_size <= 0: + raise RuntimeError( + f"Выходной файл имеет нулевой размер: {path}" + ) + + +def main() -> None: + """ + Выполняет полный набор экспериментов Lab026B. + """ + + OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True) + + print("Чтение метаданных исходного видео...") + + ( + source_width, + source_height, + source_fps, + source_frame_count, + source_duration_seconds, + source_size_bytes, + ) = read_video_metadata(SOURCE_VIDEO_PATH) + + print() + print("Исходные параметры:") + print(f" Путь: {SOURCE_VIDEO_PATH}") + print(f" Размер: {source_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() + print(f"Сформировано профилей: {len(profiles)}") + print("Последовательная обработка кадров...") + + results = process_profiles( + source_path=SOURCE_VIDEO_PATH, + profiles=profiles, + source_fps=source_fps, + source_frame_count=source_frame_count, + source_duration_seconds=source_duration_seconds, + ) + + print("Сохранение CSV...") + save_csv(results) + + print("Построение графика по разрешению...") + save_resolution_plot(results) + + print("Построение сравнения режимов...") + save_codec_comparison(results) + + print("Создание сравнительного изображения...") + save_comparison_image( + source_path=SOURCE_VIDEO_PATH, + source_frame_count=source_frame_count, + profiles=profiles, + results=results, + ) + + print("Создание текстового отчёта...") + write_report( + 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_size_bytes=source_size_bytes, + results=results, + ) + + validate_output_files() + + print() + print("Результаты профилей 320x180, 448x252 и 480x270:") + + for result in results: + if ( + (result.width, result.height) + in {(320, 180), (448, 252), (480, 270)} + ): + print(f" {format_result_line(result)}") + + print() + print("Созданы файлы:") + + for path in EXPECTED_OUTPUT_PATHS: + print(f" {path} ({path.stat().st_size} байт)") + + print() + print("Lab026B выполнена успешно.") + print( + "Для ручной оценки откройте: " + f"{EQUAL_BITRATE_COMPARISON_PATH}" + ) + + +if __name__ == "__main__": + main()