Skip to content

Commit cf92249

Browse files
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>
1 parent d4020e6 commit cf92249

3 files changed

Lines changed: 155 additions & 3 deletions

File tree

src/zm_image.cpp

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1248,6 +1248,33 @@ Image *Image::HighlightEdges(
12481248
}
12491249
}
12501250
}
1251+
} else if ( zm_is_yuv420(p_pixfmt) ) {
1252+
// Single alarm colour over a transparent (Y=0) background; Overlay() onto
1253+
// a YUV420 image keys on a non-zero luma marker. Write the colour's luma
1254+
// at each edge pixel and its chroma at the shared 2x2 chroma sample.
1255+
const YUV yuv = brg_to_yuv(colour);
1256+
const uint8_t Yc = Y_VAL(yuv), Uc = U_VAL(yuv), Vc = V_VAL(yuv);
1257+
uint8_t *hplane[4] = {};
1258+
int hstride[4] = {};
1259+
if (av_image_fill_arrays(hplane, hstride, high_buff, p_pixfmt, width, height, 32) < 0) {
1260+
Error("HighlightEdges: av_image_fill_arrays failed for YUV420 %ux%u", width, height);
1261+
return high_image;
1262+
}
1263+
for ( unsigned int y = lo_y; y <= hi_y; y++ ) {
1264+
const uint8_t* p = buffer + (y * src_linesize) + lo_x;
1265+
for ( unsigned int x = lo_x; x <= hi_x; x++, p++ ) {
1266+
bool edge = false;
1267+
if ( *p ) {
1268+
edge = (x > 0 && !*(p-1)) || (x < (width-1) && !*(p+1))
1269+
|| (y > 0 && !*(p-src_linesize)) || (y < (height-1) && !*(p+src_linesize));
1270+
}
1271+
if ( edge ) {
1272+
hplane[0][y * hstride[0] + x] = Yc ? Yc : 1; // keep the luma marker non-zero
1273+
hplane[1][(y / 2) * hstride[1] + (x / 2)] = Uc;
1274+
hplane[2][(y / 2) * hstride[2] + (x / 2)] = Vc;
1275+
}
1276+
}
1277+
}
12511278
}
12521279

12531280
return high_image;
@@ -1998,8 +2025,51 @@ void Image::Overlay( const Image &image ) {
19982025
// chroma. After Colourise() the destination's linesize is updated to the
19992026
// new format's stride, so we re-read it inside each branch.
20002027

2001-
/* Grayscale/YUV420 on top of grayscale/YUV420 - complete */
2002-
if ( zm_bytes_per_pixel(imagePixFormat) == 1 && zm_bytes_per_pixel(image.imagePixFormat) == 1 ) {
2028+
/* YUV420 on top of YUV420 - copy luma + chroma using luma as the mask */
2029+
if ( zm_is_yuv420(imagePixFormat) && zm_is_yuv420(image.imagePixFormat) ) {
2030+
// The overlay (a zone alarm highlight) is built in the target's format
2031+
// with a Clear()ed (Y=0) transparent background, so a non-zero source
2032+
// luma marks a pixel to paint. Copy that luma, and copy the shared
2033+
// chroma sample whenever any of the luma pixels it covers is marked.
2034+
uint8_t *dplane[4] = {};
2035+
int dstride[4] = {};
2036+
const uint8_t *splane[4] = {};
2037+
int sstride[4] = {};
2038+
if (av_image_fill_arrays(dplane, dstride, buffer, imagePixFormat, width, height, 32) < 0
2039+
|| av_image_fill_arrays(const_cast<uint8_t **>(splane), sstride, image.buffer,
2040+
image.imagePixFormat, width, height, 32) < 0) {
2041+
Error("Overlay: av_image_fill_arrays failed for YUV420 %ux%u", width, height);
2042+
return;
2043+
}
2044+
for (unsigned int y = 0; y < height; y++) {
2045+
const uint8_t *psrc = splane[0] + y * sstride[0];
2046+
uint8_t *pdest = dplane[0] + y * dstride[0];
2047+
for (unsigned int x = 0; x < width; x++) {
2048+
if (psrc[x]) pdest[x] = psrc[x];
2049+
}
2050+
}
2051+
const unsigned int cw = (width + 1) / 2;
2052+
const unsigned int ch = (height + 1) / 2;
2053+
for (unsigned int cy = 0; cy < ch; cy++) {
2054+
for (unsigned int cx = 0; cx < cw; cx++) {
2055+
bool marked = false;
2056+
for (unsigned int dy = 0; dy < 2 && !marked; dy++) {
2057+
const unsigned int ly = cy * 2 + dy;
2058+
if (ly >= height) break;
2059+
for (unsigned int dx = 0; dx < 2; dx++) {
2060+
const unsigned int lx = cx * 2 + dx;
2061+
if (lx < width && splane[0][ly * sstride[0] + lx]) { marked = true; break; }
2062+
}
2063+
}
2064+
if (marked) {
2065+
dplane[1][cy * dstride[1] + cx] = splane[1][cy * sstride[1] + cx];
2066+
dplane[2][cy * dstride[2] + cx] = splane[2][cy * sstride[2] + cx];
2067+
}
2068+
}
2069+
}
2070+
2071+
/* Grayscale/YUV420 on top of grayscale/YUV420 - complete */
2072+
} else if ( zm_bytes_per_pixel(imagePixFormat) == 1 && zm_bytes_per_pixel(image.imagePixFormat) == 1 ) {
20032073
// Overlay only the luma/primary plane. Width is shared (panic above).
20042074
for (unsigned int y = 0; y < height; y++) {
20052075
const uint8_t *psrc = image.buffer + y * image.linesize;

src/zm_zone.cpp

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -790,7 +790,13 @@ bool Zone::CheckAlarms(const Image *delta_image) {
790790
}
791791
} // end for y
792792

793-
if (monitor->Colours() == ZM_COLOUR_GRAY8) {
793+
// Build the alarm highlight in the capture's own pixel format so Overlay()
794+
// onto the analysis image is a same-format copy. monitor->Colours()==1
795+
// aliases both GRAY8 and planar YUV420P, so resolve the real format first:
796+
// a true GRAY8 monitor has no colour to carry, so upgrade its highlight to
797+
// RGB24; YUV420P keeps its format and carries the alarm colour in chroma.
798+
AVPixelFormat capture_fmt = zm_pixformat_from_colours(monitor->Colours(), monitor->SubpixelOrder());
799+
if (capture_fmt == AV_PIX_FMT_GRAY8) {
794800
image = diff_image->HighlightEdges(alarm_rgb, ZM_COLOUR_RGB24, ZM_SUBPIX_ORDER_RGB, &polygon.Extent());
795801
} else {
796802
image = diff_image->HighlightEdges(alarm_rgb, monitor->Colours(), monitor->SubpixelOrder(), &polygon.Extent());

tests/zm_image.cpp

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,3 +207,79 @@ TEST_CASE("Image::Flip YUV420P", "[image]") {
207207
REQUIRE(dst.data[1][(h / 2 - 1 - 30) * dst.stride[1] + 40] == 210);
208208
}
209209
}
210+
211+
// Regression: a zone alarm highlight (RGB) overlaid onto a planar YUV420P
212+
// analysis image used to call Colourise() — which only handles GRAY8, so it
213+
// bailed with "already colourised, colours: 1" (the GRAY8/YUV420P alias) and
214+
// then walked the 1.5x planar buffer as 3x packed RGB (OOB write). Highlights
215+
// are now built in the target's YUV420P format and Overlay() copies luma plus
216+
// the shared chroma sample, keyed on a non-zero source luma marker.
217+
TEST_CASE("Image::Overlay YUV420P highlight", "[image]") {
218+
bootstrap_image_config();
219+
const int w = 64, h = 48;
220+
221+
Image target(w, h, ZM_COLOUR_GRAY8, ZM_SUBPIX_ORDER_YUV420P);
222+
Planes tgt = plane_view(target, AV_PIX_FMT_YUV420P, w, h);
223+
memset(tgt.data[0], 16, static_cast<size_t>(tgt.stride[0]) * h); // dim luma
224+
memset(tgt.data[1], 128, static_cast<size_t>(tgt.stride[1]) * (h / 2)); // neutral chroma
225+
memset(tgt.data[2], 128, static_cast<size_t>(tgt.stride[2]) * (h / 2));
226+
227+
// Transparent (Y=0) highlight with a 2x2 luma marker fully covering chroma
228+
// sample (5,5), plus a single-pixel marker partially covering sample (10,10).
229+
Image high(w, h, ZM_COLOUR_GRAY8, ZM_SUBPIX_ORDER_YUV420P);
230+
Planes hi = plane_view(high, AV_PIX_FMT_YUV420P, w, h);
231+
memset(hi.data[0], 0, static_cast<size_t>(hi.stride[0]) * h);
232+
memset(hi.data[1], 0, static_cast<size_t>(hi.stride[1]) * (h / 2));
233+
memset(hi.data[2], 0, static_cast<size_t>(hi.stride[2]) * (h / 2));
234+
for (int y : {10, 11}) for (int x : {10, 11}) hi.data[0][y * hi.stride[0] + x] = 200;
235+
hi.data[1][5 * hi.stride[1] + 5] = 84; // red-ish chroma
236+
hi.data[2][5 * hi.stride[2] + 5] = 255;
237+
hi.data[0][20 * hi.stride[0] + 20] = 150; // single covering pixel of sample (10,10)
238+
hi.data[1][10 * hi.stride[1] + 10] = 43; // green-ish chroma
239+
hi.data[2][10 * hi.stride[2] + 10] = 21;
240+
241+
target.Overlay(high);
242+
243+
Planes out = plane_view(target, AV_PIX_FMT_YUV420P, w, h);
244+
// Marked luma copied; unmarked luma untouched.
245+
for (int y : {10, 11}) for (int x : {10, 11})
246+
CHECK(out.data[0][y * out.stride[0] + x] == 200);
247+
CHECK(out.data[0][20 * out.stride[0] + 20] == 150);
248+
CHECK(out.data[0][0] == 16);
249+
CHECK(out.data[0][5 * out.stride[0] + 5] == 16);
250+
// Chroma copied for any covered sample; untouched elsewhere.
251+
CHECK(out.data[1][5 * out.stride[1] + 5] == 84);
252+
CHECK(out.data[2][5 * out.stride[2] + 5] == 255);
253+
CHECK(out.data[1][10 * out.stride[1] + 10] == 43);
254+
CHECK(out.data[2][10 * out.stride[2] + 10] == 21);
255+
CHECK(out.data[1][0] == 128);
256+
CHECK(out.data[2][0] == 128);
257+
}
258+
259+
// HighlightEdges must be able to emit a YUV420P highlight (so it can be
260+
// overlaid onto a YUV420P analysis image in the same format). A filled blob's
261+
// border pixels become non-zero luma markers carrying the alarm chroma.
262+
TEST_CASE("Image::HighlightEdges YUV420P output", "[image]") {
263+
bootstrap_image_config();
264+
const int w = 64, h = 48;
265+
266+
Image src(w, h, ZM_COLOUR_GRAY8, ZM_SUBPIX_ORDER_NONE);
267+
src.Clear();
268+
// Fill an interior rectangle so it has edges away from the image border.
269+
for (int y = 10; y < 30; y++)
270+
for (int x = 10; x < 40; x++)
271+
src.Buffer()[y * src.LineSize() + x] = 255;
272+
273+
Rgb red = kRGBRed; // alarm colour
274+
Image *high = src.HighlightEdges(red, ZM_COLOUR_GRAY8, ZM_SUBPIX_ORDER_YUV420P, nullptr);
275+
REQUIRE(high != nullptr);
276+
REQUIRE(high->PixFormat() == AV_PIX_FMT_YUV420P);
277+
278+
Planes hi = plane_view(*high, AV_PIX_FMT_YUV420P, w, h);
279+
// A border pixel of the blob must carry a non-zero luma marker; an interior
280+
// pixel (not an edge) and an outside pixel must stay transparent (Y=0).
281+
CHECK(hi.data[0][10 * hi.stride[0] + 20] != 0); // top edge
282+
CHECK(hi.data[0][20 * hi.stride[0] + 25] == 0); // interior, not an edge
283+
CHECK(hi.data[0][0] == 0); // outside the blob
284+
delete high;
285+
}

0 commit comments

Comments
 (0)