fix: build zone alarm highlights in YUV420P to stop spurious Colourise warning

The "Target image is already colourised, colours: 1" warning fired from
Image::Colourise() during zone alarm overlay on YUV420P monitors, even with
ZM_COLOUR_JPEG_FILES off.

monitor->Colours() returns 1 for both GRAY8 and planar YUV420P (the GRAY8
alias), so zm_zone.cpp misclassified YUV420P monitors as grayscale and built
an RGB24 alarm highlight. Overlaying that RGB24 highlight onto the YUV420P
analysis_image hit the RGB-on-mono branch, which calls Colourise() - valid
only for GRAY8. Colourise() warned and bailed, after which the per-pixel loop
walked the 1.5x planar buffer as 3x packed RGB (out-of-bounds write).

Resolve the real capture format via zm_pixformat_from_colours() so true GRAY8
still upgrades to an RGB24 highlight while YUV420P keeps its format. Add a
YUV420P output branch to HighlightEdges (single alarm colour written to luma
and the shared chroma sample, transparent elsewhere) and a YUV420P-on-YUV420P
branch to Overlay (copies luma where non-zero, copies each chroma sample when
any covered luma pixel is marked). Colourise() is now only reached for GRAY8.

Add Catch2 coverage for the YUV420P Overlay masking and HighlightEdges output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Isaac Connor
2026-06-26 16:10:07 -04:00
parent d4020e6394
commit cf92249820
3 changed files with 155 additions and 3 deletions

View File

@@ -1248,6 +1248,33 @@ Image *Image::HighlightEdges(
}
}
}
} else if ( zm_is_yuv420(p_pixfmt) ) {
// Single alarm colour over a transparent (Y=0) background; Overlay() onto
// a YUV420 image keys on a non-zero luma marker. Write the colour's luma
// at each edge pixel and its chroma at the shared 2x2 chroma sample.
const YUV yuv = brg_to_yuv(colour);
const uint8_t Yc = Y_VAL(yuv), Uc = U_VAL(yuv), Vc = V_VAL(yuv);
uint8_t *hplane[4] = {};
int hstride[4] = {};
if (av_image_fill_arrays(hplane, hstride, high_buff, p_pixfmt, width, height, 32) < 0) {
Error("HighlightEdges: av_image_fill_arrays failed for YUV420 %ux%u", width, height);
return high_image;
}
for ( unsigned int y = lo_y; y <= hi_y; y++ ) {
const uint8_t* p = buffer + (y * src_linesize) + lo_x;
for ( unsigned int x = lo_x; x <= hi_x; x++, p++ ) {
bool edge = false;
if ( *p ) {
edge = (x > 0 && !*(p-1)) || (x < (width-1) && !*(p+1))
|| (y > 0 && !*(p-src_linesize)) || (y < (height-1) && !*(p+src_linesize));
}
if ( edge ) {
hplane[0][y * hstride[0] + x] = Yc ? Yc : 1; // keep the luma marker non-zero
hplane[1][(y / 2) * hstride[1] + (x / 2)] = Uc;
hplane[2][(y / 2) * hstride[2] + (x / 2)] = Vc;
}
}
}
}
return high_image;
@@ -1998,8 +2025,51 @@ void Image::Overlay( const Image &image ) {
// chroma. After Colourise() the destination's linesize is updated to the
// new format's stride, so we re-read it inside each branch.
/* Grayscale/YUV420 on top of grayscale/YUV420 - complete */
if ( zm_bytes_per_pixel(imagePixFormat) == 1 && zm_bytes_per_pixel(image.imagePixFormat) == 1 ) {
/* YUV420 on top of YUV420 - copy luma + chroma using luma as the mask */
if ( zm_is_yuv420(imagePixFormat) && zm_is_yuv420(image.imagePixFormat) ) {
// The overlay (a zone alarm highlight) is built in the target's format
// with a Clear()ed (Y=0) transparent background, so a non-zero source
// luma marks a pixel to paint. Copy that luma, and copy the shared
// chroma sample whenever any of the luma pixels it covers is marked.
uint8_t *dplane[4] = {};
int dstride[4] = {};
const uint8_t *splane[4] = {};
int sstride[4] = {};
if (av_image_fill_arrays(dplane, dstride, buffer, imagePixFormat, width, height, 32) < 0
|| av_image_fill_arrays(const_cast<uint8_t **>(splane), sstride, image.buffer,
image.imagePixFormat, width, height, 32) < 0) {
Error("Overlay: av_image_fill_arrays failed for YUV420 %ux%u", width, height);
return;
}
for (unsigned int y = 0; y < height; y++) {
const uint8_t *psrc = splane[0] + y * sstride[0];
uint8_t *pdest = dplane[0] + y * dstride[0];
for (unsigned int x = 0; x < width; x++) {
if (psrc[x]) pdest[x] = psrc[x];
}
}
const unsigned int cw = (width + 1) / 2;
const unsigned int ch = (height + 1) / 2;
for (unsigned int cy = 0; cy < ch; cy++) {
for (unsigned int cx = 0; cx < cw; cx++) {
bool marked = false;
for (unsigned int dy = 0; dy < 2 && !marked; dy++) {
const unsigned int ly = cy * 2 + dy;
if (ly >= height) break;
for (unsigned int dx = 0; dx < 2; dx++) {
const unsigned int lx = cx * 2 + dx;
if (lx < width && splane[0][ly * sstride[0] + lx]) { marked = true; break; }
}
}
if (marked) {
dplane[1][cy * dstride[1] + cx] = splane[1][cy * sstride[1] + cx];
dplane[2][cy * dstride[2] + cx] = splane[2][cy * sstride[2] + cx];
}
}
}
/* Grayscale/YUV420 on top of grayscale/YUV420 - complete */
} else if ( zm_bytes_per_pixel(imagePixFormat) == 1 && zm_bytes_per_pixel(image.imagePixFormat) == 1 ) {
// Overlay only the luma/primary plane. Width is shared (panic above).
for (unsigned int y = 0; y < height; y++) {
const uint8_t *psrc = image.buffer + y * image.linesize;

View File

@@ -790,7 +790,13 @@ bool Zone::CheckAlarms(const Image *delta_image) {
}
} // end for y
if (monitor->Colours() == ZM_COLOUR_GRAY8) {
// Build the alarm highlight in the capture's own pixel format so Overlay()
// onto the analysis image is a same-format copy. monitor->Colours()==1
// aliases both GRAY8 and planar YUV420P, so resolve the real format first:
// a true GRAY8 monitor has no colour to carry, so upgrade its highlight to
// RGB24; YUV420P keeps its format and carries the alarm colour in chroma.
AVPixelFormat capture_fmt = zm_pixformat_from_colours(monitor->Colours(), monitor->SubpixelOrder());
if (capture_fmt == AV_PIX_FMT_GRAY8) {
image = diff_image->HighlightEdges(alarm_rgb, ZM_COLOUR_RGB24, ZM_SUBPIX_ORDER_RGB, &polygon.Extent());
} else {
image = diff_image->HighlightEdges(alarm_rgb, monitor->Colours(), monitor->SubpixelOrder(), &polygon.Extent());

View File

@@ -207,3 +207,79 @@ TEST_CASE("Image::Flip YUV420P", "[image]") {
REQUIRE(dst.data[1][(h / 2 - 1 - 30) * dst.stride[1] + 40] == 210);
}
}
// Regression: a zone alarm highlight (RGB) overlaid onto a planar YUV420P
// analysis image used to call Colourise() — which only handles GRAY8, so it
// bailed with "already colourised, colours: 1" (the GRAY8/YUV420P alias) and
// then walked the 1.5x planar buffer as 3x packed RGB (OOB write). Highlights
// are now built in the target's YUV420P format and Overlay() copies luma plus
// the shared chroma sample, keyed on a non-zero source luma marker.
TEST_CASE("Image::Overlay YUV420P highlight", "[image]") {
bootstrap_image_config();
const int w = 64, h = 48;
Image target(w, h, ZM_COLOUR_GRAY8, ZM_SUBPIX_ORDER_YUV420P);
Planes tgt = plane_view(target, AV_PIX_FMT_YUV420P, w, h);
memset(tgt.data[0], 16, static_cast<size_t>(tgt.stride[0]) * h); // dim luma
memset(tgt.data[1], 128, static_cast<size_t>(tgt.stride[1]) * (h / 2)); // neutral chroma
memset(tgt.data[2], 128, static_cast<size_t>(tgt.stride[2]) * (h / 2));
// Transparent (Y=0) highlight with a 2x2 luma marker fully covering chroma
// sample (5,5), plus a single-pixel marker partially covering sample (10,10).
Image high(w, h, ZM_COLOUR_GRAY8, ZM_SUBPIX_ORDER_YUV420P);
Planes hi = plane_view(high, AV_PIX_FMT_YUV420P, w, h);
memset(hi.data[0], 0, static_cast<size_t>(hi.stride[0]) * h);
memset(hi.data[1], 0, static_cast<size_t>(hi.stride[1]) * (h / 2));
memset(hi.data[2], 0, static_cast<size_t>(hi.stride[2]) * (h / 2));
for (int y : {10, 11}) for (int x : {10, 11}) hi.data[0][y * hi.stride[0] + x] = 200;
hi.data[1][5 * hi.stride[1] + 5] = 84; // red-ish chroma
hi.data[2][5 * hi.stride[2] + 5] = 255;
hi.data[0][20 * hi.stride[0] + 20] = 150; // single covering pixel of sample (10,10)
hi.data[1][10 * hi.stride[1] + 10] = 43; // green-ish chroma
hi.data[2][10 * hi.stride[2] + 10] = 21;
target.Overlay(high);
Planes out = plane_view(target, AV_PIX_FMT_YUV420P, w, h);
// Marked luma copied; unmarked luma untouched.
for (int y : {10, 11}) for (int x : {10, 11})
CHECK(out.data[0][y * out.stride[0] + x] == 200);
CHECK(out.data[0][20 * out.stride[0] + 20] == 150);
CHECK(out.data[0][0] == 16);
CHECK(out.data[0][5 * out.stride[0] + 5] == 16);
// Chroma copied for any covered sample; untouched elsewhere.
CHECK(out.data[1][5 * out.stride[1] + 5] == 84);
CHECK(out.data[2][5 * out.stride[2] + 5] == 255);
CHECK(out.data[1][10 * out.stride[1] + 10] == 43);
CHECK(out.data[2][10 * out.stride[2] + 10] == 21);
CHECK(out.data[1][0] == 128);
CHECK(out.data[2][0] == 128);
}
// HighlightEdges must be able to emit a YUV420P highlight (so it can be
// overlaid onto a YUV420P analysis image in the same format). A filled blob's
// border pixels become non-zero luma markers carrying the alarm chroma.
TEST_CASE("Image::HighlightEdges YUV420P output", "[image]") {
bootstrap_image_config();
const int w = 64, h = 48;
Image src(w, h, ZM_COLOUR_GRAY8, ZM_SUBPIX_ORDER_NONE);
src.Clear();
// Fill an interior rectangle so it has edges away from the image border.
for (int y = 10; y < 30; y++)
for (int x = 10; x < 40; x++)
src.Buffer()[y * src.LineSize() + x] = 255;
Rgb red = kRGBRed; // alarm colour
Image *high = src.HighlightEdges(red, ZM_COLOUR_GRAY8, ZM_SUBPIX_ORDER_YUV420P, nullptr);
REQUIRE(high != nullptr);
REQUIRE(high->PixFormat() == AV_PIX_FMT_YUV420P);
Planes hi = plane_view(*high, AV_PIX_FMT_YUV420P, w, h);
// A border pixel of the blob must carry a non-zero luma marker; an interior
// pixel (not an edge) and an outside pixel must stay transparent (Y=0).
CHECK(hi.data[0][10 * hi.stride[0] + 20] != 0); // top edge
CHECK(hi.data[0][20 * hi.stride[0] + 25] == 0); // interior, not an edge
CHECK(hi.data[0][0] == 0); // outside the blob
delete high;
}