Skip to content

Commit 346a4ba

Browse files
feat: preserve full-range colour when converting YUVJ frames via swscale
swscale assumes limited (MPEG, 16-235) input range by default. Mapping a full-range JPEG format (YUVJ*) to its non-J equivalent for the conversion therefore washes the colours out, because full-range luma/chroma gets treated as limited. Add pix_fmt_is_jpeg_range() and zm_sws_set_input_range(), which sets srcRange=1 on the context when the original source format was full range. Apply it at every conversion site that takes decoded frames: Image::Assign and both SWScale::Convert overloads. Monitor::setupConvertContext already did this inline with a hand-rolled switch and colorspace block; replace that with the shared helpers to remove the duplication. Add tests/zm_swscale_range.cpp: converting a Y=16 YUVJ420P image to RGB24 keeps luma ~16 (full range) rather than being crushed to ~0 (limited). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 18170ec commit 346a4ba

7 files changed

Lines changed: 120 additions & 35 deletions

File tree

src/zm_ffmpeg.cpp

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,43 @@ enum AVPixelFormat fix_deprecated_pix_fmt(enum AVPixelFormat fmt) {
577577
}
578578
}
579579

580+
bool pix_fmt_is_jpeg_range(enum AVPixelFormat fmt) {
581+
// The deprecated YUVJ* formats carry full-range (0-255) luma/chroma, as
582+
// opposed to the limited/broadcast range (16-235) of their non-J equivalents.
583+
switch (fmt) {
584+
case AV_PIX_FMT_YUVJ411P:
585+
case AV_PIX_FMT_YUVJ420P:
586+
case AV_PIX_FMT_YUVJ422P:
587+
case AV_PIX_FMT_YUVJ440P:
588+
case AV_PIX_FMT_YUVJ444P:
589+
return true;
590+
default:
591+
return false;
592+
}
593+
}
594+
595+
void zm_sws_set_input_range(struct SwsContext *ctx, enum AVPixelFormat original_src_fmt) {
596+
// swscale assumes limited (MPEG) input range by default. When the decoded
597+
// source was a full-range JPEG format (YUVJ*) that got mapped to its non-J
598+
// equivalent by fix_deprecated_pix_fmt(), swscale would otherwise treat the
599+
// full-range samples as limited and wash the colours out. Tell it the input
600+
// is full range so the YUV->RGB / YUV->YUV maths is correct. Pass the
601+
// ORIGINAL (pre-fix) format so we can tell whether the source was full range.
602+
if (!pix_fmt_is_jpeg_range(original_src_fmt)) return;
603+
604+
int *inv_table, *table;
605+
int srcRange, dstRange, brightness, contrast, saturation;
606+
// Returns < 0 when the conversion does not expose colorspace details (e.g.
607+
// RGB->RGB); nothing to correct in that case.
608+
if (sws_getColorspaceDetails(ctx, &inv_table, &srcRange, &table, &dstRange,
609+
&brightness, &contrast, &saturation) < 0)
610+
return;
611+
if (srcRange == 1) return; // already full range
612+
srcRange = 1;
613+
sws_setColorspaceDetails(ctx, inv_table, srcRange, table, dstRange,
614+
brightness, contrast, saturation);
615+
}
616+
580617
bool is_video_stream(const AVStream * stream) {
581618
if (stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
582619
return true;

src/zm_ffmpeg.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,11 @@ void zm_dump_codecpar(const AVCodecParameters *par);
252252

253253
int check_sample_fmt(const AVCodec *codec, enum AVSampleFormat sample_fmt);
254254
enum AVPixelFormat fix_deprecated_pix_fmt(enum AVPixelFormat );
255+
bool pix_fmt_is_jpeg_range(enum AVPixelFormat );
256+
// Correct swscale's default limited-range assumption when the original decoded
257+
// source was a full-range JPEG (YUVJ*) format. Call after (re)creating the
258+
// context, passing the ORIGINAL pre-fix_deprecated_pix_fmt source format.
259+
void zm_sws_set_input_range(struct SwsContext *ctx, enum AVPixelFormat original_src_fmt);
255260

256261
bool is_video_stream(const AVStream *);
257262
bool is_audio_stream(const AVStream *);

src/zm_image.cpp

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,8 @@ bool Image::Assign(const AVFrame *frame) {
376376
// format to swscale. Passing YUVJ420P/YUVJ422P/etc directly makes swscale emit
377377
// "deprecated pixel format used, make sure you did set range correctly" (seen
378378
// in nph-zms). This mirrors what SWScale::Convert already does.
379-
const AVPixelFormat src_fmt = fix_deprecated_pix_fmt(static_cast<AVPixelFormat>(frame->format));
379+
const AVPixelFormat orig_src_fmt = static_cast<AVPixelFormat>(frame->format);
380+
const AVPixelFormat src_fmt = fix_deprecated_pix_fmt(orig_src_fmt);
380381

381382
// If source and destination format + dimensions match, do a direct plane
382383
// copy instead of running through sws_scale. This avoids the overhead of
@@ -418,6 +419,7 @@ bool Image::Assign(const AVFrame *frame) {
418419
Error("Unable to create conversion context");
419420
return false;
420421
}
422+
zm_sws_set_input_range(sws_convert_context, orig_src_fmt);
421423
bool result = Assign(frame, sws_convert_context);
422424
update_function_pointers();
423425
return result;

src/zm_monitor.cpp

Lines changed: 5 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2946,28 +2946,8 @@ int Monitor::Capture() {
29462946

29472947
bool Monitor::setupConvertContext(const AVFrame *input_frame, const Image *image) {
29482948
AVPixelFormat imagePixFormat = image->AVPixFormat();
2949-
AVPixelFormat inputPixFormat;
2950-
bool changeColorspaceDetails = false;
2951-
switch (input_frame->format) {
2952-
case AV_PIX_FMT_YUVJ420P:
2953-
inputPixFormat = AV_PIX_FMT_YUV420P;
2954-
changeColorspaceDetails = true;
2955-
break;
2956-
case AV_PIX_FMT_YUVJ422P:
2957-
inputPixFormat = AV_PIX_FMT_YUV422P;
2958-
changeColorspaceDetails = true;
2959-
break;
2960-
case AV_PIX_FMT_YUVJ444P:
2961-
inputPixFormat = AV_PIX_FMT_YUV444P;
2962-
changeColorspaceDetails = true;
2963-
break;
2964-
case AV_PIX_FMT_YUVJ440P:
2965-
inputPixFormat = AV_PIX_FMT_YUV440P;
2966-
changeColorspaceDetails = true;
2967-
break;
2968-
default:
2969-
inputPixFormat = (AVPixelFormat)input_frame->format;
2970-
}
2949+
AVPixelFormat origPixFormat = (AVPixelFormat)input_frame->format;
2950+
AVPixelFormat inputPixFormat = fix_deprecated_pix_fmt(origPixFormat);
29712951

29722952
convert_context = sws_getContext(
29732953
input_frame->width,
@@ -2988,17 +2968,9 @@ bool Monitor::setupConvertContext(const AVFrame *input_frame, const Image *image
29882968
image->Width(), image->Height(),
29892969
av_get_pix_fmt_name(imagePixFormat)
29902970
);
2991-
if (changeColorspaceDetails) {
2992-
// change the range of input data by first reading the current color space and then setting it's range as yuvj.
2993-
int dummy[4];
2994-
int srcRange, dstRange;
2995-
int brightness, contrast, saturation;
2996-
sws_getColorspaceDetails(convert_context, (int**)&dummy, &srcRange, (int**)&dummy, &dstRange, &brightness, &contrast, &saturation);
2997-
const int* coefs = sws_getCoefficients(SWS_CS_DEFAULT);
2998-
srcRange = 1; // this marks that values are according to yuvj
2999-
sws_setColorspaceDetails(convert_context, coefs, srcRange, coefs, dstRange,
3000-
brightness, contrast, saturation);
3001-
}
2971+
// Mark the input as full range when the source was a YUVJ* format so the
2972+
// conversion maths doesn't crush full-range luma into limited range.
2973+
zm_sws_set_input_range(convert_context, origPixFormat);
30022974
}
30032975
return (convert_context != nullptr);
30042976
}

src/zm_swscale.cpp

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ int SWScale::Convert(
5858
AVFrame *out_frame
5959
) {
6060

61-
AVPixelFormat format = fix_deprecated_pix_fmt((AVPixelFormat)in_frame->format);
61+
AVPixelFormat orig_format = (AVPixelFormat)in_frame->format;
62+
AVPixelFormat format = fix_deprecated_pix_fmt(orig_format);
6263
/* Get the context */
6364
swscale_ctx = sws_getCachedContext(swscale_ctx,
6465
in_frame->width, in_frame->height, format,
@@ -68,6 +69,7 @@ int SWScale::Convert(
6869
Error("Failed getting swscale context");
6970
return -6;
7071
}
72+
zm_sws_set_input_range(swscale_ctx, orig_format);
7173
/* Do the conversion */
7274
if (!sws_scale(swscale_ctx,
7375
in_frame->data, in_frame->linesize, 0, in_frame->height,
@@ -114,6 +116,7 @@ int SWScale::Convert(
114116
return -4;
115117
}
116118

119+
const enum _AVPIXELFORMAT orig_in_pf = in_pf;
117120
in_pf = fix_deprecated_pix_fmt(in_pf);
118121

119122
/* Warn if the input or output pixelformat is not supported */
@@ -155,6 +158,7 @@ int SWScale::Convert(
155158
Error("Failed getting swscale context");
156159
return -6;
157160
}
161+
zm_sws_set_input_range(swscale_ctx, orig_in_pf);
158162

159163
/* Fill in the buffers. The alignments describe how the caller's buffers
160164
* are actually laid out — they are facts about the buffers, not tuning

tests/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ set(TEST_SOURCES
2323
zm_onvif_renewal.cpp
2424
zm_onvif_wsse.cpp
2525
zm_pixformat.cpp
26+
zm_swscale_range.cpp
2627
zm_poly.cpp
2728
zm_time.cpp
2829
zm_utils.cpp

tests/zm_swscale_range.cpp

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
* This file is part of the ZoneMinder Project. See AUTHORS file for Copyright information
3+
*
4+
* This program is free software; you can redistribute it and/or modify it
5+
* under the terms of the GNU General Public License as published by the
6+
* Free Software Foundation; either version 2 of the License, or (at your
7+
* option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT
10+
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
11+
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
12+
* more details.
13+
*
14+
* You should have received a copy of the GNU General Public License along
15+
* with this program. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
#include "zm_catch2.h"
19+
20+
#include "zm_ffmpeg.h"
21+
#include "zm_swscale.h"
22+
23+
#include <cstdlib>
24+
#include <vector>
25+
26+
TEST_CASE("pix_fmt_is_jpeg_range identifies full-range YUVJ formats", "[swscale]") {
27+
REQUIRE(pix_fmt_is_jpeg_range(AV_PIX_FMT_YUVJ420P));
28+
REQUIRE(pix_fmt_is_jpeg_range(AV_PIX_FMT_YUVJ422P));
29+
REQUIRE(pix_fmt_is_jpeg_range(AV_PIX_FMT_YUVJ444P));
30+
REQUIRE(pix_fmt_is_jpeg_range(AV_PIX_FMT_YUVJ440P));
31+
32+
REQUIRE_FALSE(pix_fmt_is_jpeg_range(AV_PIX_FMT_YUV420P));
33+
REQUIRE_FALSE(pix_fmt_is_jpeg_range(AV_PIX_FMT_YUV422P));
34+
REQUIRE_FALSE(pix_fmt_is_jpeg_range(AV_PIX_FMT_RGB24));
35+
REQUIRE_FALSE(pix_fmt_is_jpeg_range(AV_PIX_FMT_GRAY8));
36+
}
37+
38+
// Colorimetric regression: a full-range JPEG (YUVJ420P) frame carries luma in
39+
// 0-255. swscale defaults to limited (16-235) input range, which would crush a
40+
// dark grey Y=16 down to ~0 (black). zm_sws_set_input_range() must mark the
41+
// input full range so Y=16 survives as ~16.
42+
TEST_CASE("SWScale treats YUVJ420P input as full range when converting to RGB", "[swscale]") {
43+
const int w = 16, h = 16;
44+
const uint8_t Y = 16; // full range -> ~16; limited range -> ~0
45+
46+
// YUVJ420P planar: Y plane (w*h), then U and V (w/2*h/2). Neutral chroma=128.
47+
std::vector<uint8_t> in(SWScale::GetBufferSize(AV_PIX_FMT_YUVJ420P, w, h, 1), 128);
48+
std::fill(in.begin(), in.begin() + w * h, Y);
49+
50+
std::vector<uint8_t> out(SWScale::GetBufferSize(AV_PIX_FMT_RGB24, w, h, 1), 0);
51+
52+
SWScale scaler;
53+
REQUIRE(scaler.init());
54+
int r = scaler.Convert(in.data(), in.size(), out.data(), out.size(),
55+
AV_PIX_FMT_YUVJ420P, AV_PIX_FMT_RGB24, w, h, 1, 1);
56+
REQUIRE(r == 0);
57+
58+
// Full-range interpretation keeps luma ~16; limited-range would crush to ~0.
59+
REQUIRE(out[0] >= 12);
60+
REQUIRE(out[0] <= 20);
61+
// Neutral chroma -> grey, so the three channels stay equal.
62+
REQUIRE(std::abs(static_cast<int>(out[0]) - static_cast<int>(out[1])) <= 2);
63+
REQUIRE(std::abs(static_cast<int>(out[1]) - static_cast<int>(out[2])) <= 2);
64+
}

0 commit comments

Comments
 (0)