diff --git a/src/zm_image.cpp b/src/zm_image.cpp index 0c409b087..d336d35e5 100644 --- a/src/zm_image.cpp +++ b/src/zm_image.cpp @@ -1175,14 +1175,15 @@ void Image::Assign(const Image &image) { } } -Image *Image::HighlightEdges( +Image *Image::BuildHighlight( Rgb colour, unsigned int p_colours, unsigned int p_subpixelorder, - const Box *limits + const Box *limits, + bool edges_only ) { if ( imagePixFormat != AV_PIX_FMT_GRAY8 ) { - Panic("Attempt to highlight image edges when colours = %d", colours); + Panic("Attempt to highlight image when colours = %d", colours); } /* Convert the colour's RGBA subpixel order into the image's subpixel order */ @@ -1215,12 +1216,13 @@ Image *Image::HighlightEdges( const uint8_t* p = buffer + (y * src_linesize) + lo_x; uint8_t* phigh = high_buff + (y * dst_linesize) + lo_x; for ( unsigned int x = lo_x; x <= hi_x; x++, p++, phigh++ ) { - bool edge = false; + bool mark = false; if ( *p ) { - edge = (x > 0 && !*(p-1)) || (x < (width-1) && !*(p+1)) + mark = !edges_only + || (x > 0 && !*(p-1)) || (x < (width-1) && !*(p+1)) || (y > 0 && !*(p-src_linesize)) || (y < (height-1) && !*(p+src_linesize)); } - if ( edge ) { + if ( mark ) { *phigh = colour; } } @@ -1230,12 +1232,13 @@ Image *Image::HighlightEdges( const uint8_t* p = buffer + (y * src_linesize) + lo_x; uint8_t* phigh = high_buff + (y * dst_linesize) + lo_x * 3; for ( unsigned int x = lo_x; x <= hi_x; x++, p++, phigh += 3 ) { - bool edge = false; + bool mark = false; if ( *p ) { - edge = (x > 0 && !*(p-1)) || (x < (width-1) && !*(p+1)) + mark = !edges_only + || (x > 0 && !*(p-1)) || (x < (width-1) && !*(p+1)) || (y > 0 && !*(p-src_linesize)) || (y < (height-1) && !*(p+src_linesize)); } - if ( edge ) { + if ( mark ) { RED_PTR_RGBA(phigh) = RED_VAL_RGBA(colour); GREEN_PTR_RGBA(phigh) = GREEN_VAL_RGBA(colour); BLUE_PTR_RGBA(phigh) = BLUE_VAL_RGBA(colour); @@ -1247,12 +1250,13 @@ Image *Image::HighlightEdges( const uint8_t* p = buffer + (y * src_linesize) + lo_x; Rgb* phigh = (Rgb*)(high_buff + (y * dst_linesize) + (lo_x << 2)); for ( unsigned int x = lo_x; x <= hi_x; x++, p++, phigh++ ) { - bool edge = false; + bool mark = false; if ( *p ) { - edge = (x > 0 && !*(p-1)) || (x < (width-1) && !*(p+1)) + mark = !edges_only + || (x > 0 && !*(p-1)) || (x < (width-1) && !*(p+1)) || (y > 0 && !*(p-src_linesize)) || (y < (height-1) && !*(p+src_linesize)); } - if ( edge ) { + if ( mark ) { *phigh = colour; } } @@ -1260,24 +1264,25 @@ 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. + // at each marked 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); + Error("Highlight: 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; + bool mark = false; if ( *p ) { - edge = (x > 0 && !*(p-1)) || (x < (width-1) && !*(p+1)) + mark = !edges_only + || (x > 0 && !*(p-1)) || (x < (width-1) && !*(p+1)) || (y > 0 && !*(p-src_linesize)) || (y < (height-1) && !*(p+src_linesize)); } - if ( edge ) { + if ( mark ) { 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; diff --git a/src/zm_image.h b/src/zm_image.h index 985d7f7ad..c00023797 100644 --- a/src/zm_image.h +++ b/src/zm_image.h @@ -277,7 +277,13 @@ class Image { uint8 size = 1, Rgb fg_colour = kRGBWhite, Rgb bg_colour = kRGBBlack); - Image *HighlightEdges( Rgb colour, unsigned int p_colours, unsigned int p_subpixelorder, const Box *limits=0 ); + // Build a colour highlight image from this GRAY8 mask. edges_only traces the + // outline of each marked region (what the blob check method shows); false + // fills every marked pixel (what the alarmed/filtered pixel methods show). + Image *BuildHighlight( Rgb colour, unsigned int p_colours, unsigned int p_subpixelorder, const Box *limits=0, bool edges_only=true ); + Image *HighlightEdges( Rgb colour, unsigned int p_colours, unsigned int p_subpixelorder, const Box *limits=0 ) { + return BuildHighlight(colour, p_colours, p_subpixelorder, limits, true); + } //Image *HighlightEdges( Rgb colour, const Polygon &polygon ); void Timestamp(const char *label, SystemTimePoint when, const Vector2 &coord, int label_size); void Colourise(const unsigned int p_reqcolours, const unsigned int p_reqsubpixelorder); diff --git a/src/zm_zone.cpp b/src/zm_zone.cpp index edace6d09..c3e835c6f 100644 --- a/src/zm_zone.cpp +++ b/src/zm_zone.cpp @@ -154,6 +154,24 @@ void Zone::SetScore(unsigned int nScore) { stats.score_ = nScore; } // end void Zone::SetScore(unsigned int nScore) +// Replace the GRAY8 analysis mask with a highlight in the capture's own pixel +// format, carrying this zone's alarm colour, and take ownership of it in +// `image`. Overlay() onto the analysis image is then 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. +void Zone::BuildAlarmHighlight(Image *mask, bool edges_only) { + AVPixelFormat capture_fmt = zm_pixformat_from_colours(monitor->Colours(), monitor->SubpixelOrder()); + if (capture_fmt == AV_PIX_FMT_GRAY8) { + image = mask->BuildHighlight(alarm_rgb, ZM_COLOUR_RGB24, ZM_SUBPIX_ORDER_RGB, &polygon.Extent(), edges_only); + } else { + image = mask->BuildHighlight(alarm_rgb, monitor->Colours(), monitor->SubpixelOrder(), &polygon.Extent(), edges_only); + } + // 'image' is now detached from the mask we were handed, so that is ours to free. + delete mask; +} + void Zone::SetAlarmImage(const Image* srcImage) { if ( image ) delete image; @@ -795,15 +813,19 @@ bool Zone::CheckAlarms(const Image *delta_image) { // 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()); - } - - // Only need to delete this when 'image' becomes detached and points somewhere else - delete diff_image; + BuildAlarmHighlight(diff_image, true); + diff_image = nullptr; + } else if ((type < PRECLUSIVE) && (monitor->GetOptSaveJPEGs() > 1)) { + // Alarmed/filtered pixels: the mask stays GRAY8 through the analysis + // above because the scoring reads it as one byte per pixel. Overlay() + // onto the analysis image then keys on a non-zero source pixel, so a + // GRAY8 mask painted an undifferentiated highlight -- writing the mask + // value into the red channel on an RGB32 monitor (hence "alarms are + // always red"), into all three on RGB24 (white), and into luma alone on + // YUV420 -- and the zone's configured Alarm Colour was honoured only on + // the blob path. Build the same colour highlight the blob path builds, + // filled rather than outlined, now that scoring is done with the mask. + BuildAlarmHighlight(diff_image, false); diff_image = nullptr; } // end if ( (type < PRECLUSIVE) && (check_method >= BLOBS) && (monitor->GetOptSaveJPEGs() > 1) diff --git a/src/zm_zone.h b/src/zm_zone.h index 74e744aa0..3ec8a220b 100644 --- a/src/zm_zone.h +++ b/src/zm_zone.h @@ -226,6 +226,7 @@ class Zone { int GetExtendAlarmFrames(); void SetScore(unsigned int nScore); void SetAlarmImage(const Image* srcImage); + void BuildAlarmHighlight(Image *mask, bool edges_only); inline const Image *getPgImage() const { return pg_image; } inline const Range *getRanges() const { return ranges; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2f8dbf399..8267de3b1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -41,6 +41,7 @@ set(TEST_SOURCES zm_vector2.cpp zm_zone.cpp zm_zone_stride.cpp + zm_zone_highlight.cpp zm_image_linesize.cpp zm_logger_rotate.cpp zm_ffmpeg_camera.cpp diff --git a/tests/zm_zone_highlight.cpp b/tests/zm_zone_highlight.cpp new file mode 100644 index 000000000..fef50700d --- /dev/null +++ b/tests/zm_zone_highlight.cpp @@ -0,0 +1,217 @@ +/* + * This file is part of the ZoneMinder Project. See AUTHORS file for Copyright information + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +#include "zm_catch2.h" + +#include "zm_config.h" +#include "zm_image.h" +#include "zm_monitor.h" +#include "zm_rgb.h" +#include "zm_zone.h" + +#include +#include +#include +#include + +// The alarmed/filtered pixel check methods used to hand Overlay() the raw GRAY8 +// scoring mask. Overlay keys on a non-zero source pixel and copies it, so the +// highlight took whatever shape that byte had in the destination format: the +// red channel on an RGB32 monitor (which is why alarms were "always red"), all +// three channels on RGB24 (white), luma alone on YUV420. The zone's configured +// Alarm Colour was only ever honoured on the blob path, via HighlightEdges. +// +// The highlight must now carry the zone's alarm colour on every check method +// and colour depth, filled for the pixel methods and outlined for blobs. +namespace { + +void EnsureConfig() { + if (!config.font_file_location) config.font_file_location = ""; + if (!config.event_close_mode) config.event_close_mode = "idle"; +} + +class TestMonitor : public Monitor { + public: + TestMonitor(unsigned int w, unsigned int h, unsigned int c) : Monitor() { + width = w; + height = h; + colours = c; + // >1 is what gates building the highlight at all. + savejpegs = 2; + } +}; + +std::shared_ptr MakeMonitor(unsigned int w, unsigned int h, unsigned int c) { + return std::static_pointer_cast(std::make_shared(w, h, c)); +} + +Polygon FullFramePolygon(unsigned int w, unsigned int h) { + std::vector vertices = {Vector2(0, 0), Vector2(w - 1, 0), + Vector2(w - 1, h - 1), Vector2(0, h - 1)}; + return Polygon(vertices); +} + +std::unique_ptr MakeSaturatedDelta(unsigned int w, unsigned int h) { + auto img = std::unique_ptr( + new Image(w, h, ZM_COLOUR_GRAY8, ZM_SUBPIX_ORDER_NONE)); + for (unsigned int y = 0; y < h; y++) + for (unsigned int x = 0; x < w; x++) *img->Buffer(x, y) = 255; + return img; +} + +// A colour no channel-smearing bug can produce by accident: it is not white, +// not pure red, and its three channels are all different. +constexpr Rgb kAlarmColour = 0x20c0ff; +constexpr uint8_t kR = 0x20, kG = 0xc0, kB = 0xff; + +// Monitor::SubpixelOrder() reads through the camera, which a bare test Monitor +// does not have, so which byte holds which channel is not pinned down here. +// Checking the three bytes as a set still fails for every way the old code got +// this wrong: white is {ff,ff,ff}, red-channel-only is {xx,00,00}. +bool ChannelsAre(const uint8_t *px, uint8_t a, uint8_t b, uint8_t c) { + std::vector got = {px[0], px[1], px[2]}; + std::vector want = {a, b, c}; + std::sort(got.begin(), got.end()); + std::sort(want.begin(), want.end()); + return got == want; +} + +} // namespace + +TEST_CASE("Zone alarm highlight carries the zone's alarm colour", "[Zone]") { + EnsureConfig(); + + // 720 is deliberately not 32-byte aligned in either depth, so this also + // exercises the padded-stride path the striping fix covers. + const unsigned int w = 720, h = 480; + + SECTION("RGB24 monitor: filled with the alarm colour, not white") { + auto monitor = MakeMonitor(w, h, ZM_COLOUR_RGB24); + Zone zone(monitor, 1, "full", Zone::ACTIVE, FullFramePolygon(w, h), + kAlarmColour, Zone::ALARMED_PIXELS, + /*min_pixel_threshold*/ 10, /*max_pixel_threshold*/ 0, + /*min_alarm_pixels*/ 1, /*max_alarm_pixels*/ static_cast(w * h)); + + auto delta = MakeSaturatedDelta(w, h); + REQUIRE(zone.CheckAlarms(delta.get())); + + const Image *alarm = zone.AlarmImage(); + REQUIRE(alarm != nullptr); + REQUIRE(alarm->Colours() == ZM_COLOUR_RGB24); + + const uint8_t *px = alarm->Buffer(w / 2, h / 2); + CHECK(ChannelsAre(px, kR, kG, kB)); + // The old behaviour wrote the mask byte into all three channels. + CHECK_FALSE((px[0] == 0xff && px[1] == 0xff && px[2] == 0xff)); + } + + SECTION("RGB32 monitor: alarm colour, not just the red channel") { + auto monitor = MakeMonitor(w, h, ZM_COLOUR_RGB32); + Zone zone(monitor, 1, "full", Zone::ACTIVE, FullFramePolygon(w, h), + kAlarmColour, Zone::ALARMED_PIXELS, + /*min_pixel_threshold*/ 10, /*max_pixel_threshold*/ 0, + /*min_alarm_pixels*/ 1, /*max_alarm_pixels*/ static_cast(w * h)); + + auto delta = MakeSaturatedDelta(w, h); + REQUIRE(zone.CheckAlarms(delta.get())); + + const Image *alarm = zone.AlarmImage(); + REQUIRE(alarm != nullptr); + REQUIRE(alarm->Colours() == ZM_COLOUR_RGB32); + + const uint8_t *px = alarm->Buffer(w / 2, h / 2); + CHECK(ChannelsAre(px, kR, kG, kB)); + // The old behaviour left two of the three channels at zero. + CHECK_FALSE((px[1] == 0 && px[2] == 0)); + } + + SECTION("the highlight is filled, not just outlined, for alarmed pixels") { + auto monitor = MakeMonitor(w, h, ZM_COLOUR_RGB24); + Zone zone(monitor, 1, "full", Zone::ACTIVE, FullFramePolygon(w, h), + kAlarmColour, Zone::ALARMED_PIXELS, + /*min_pixel_threshold*/ 10, /*max_pixel_threshold*/ 0, + /*min_alarm_pixels*/ 1, /*max_alarm_pixels*/ static_cast(w * h)); + + auto delta = MakeSaturatedDelta(w, h); + REQUIRE(zone.CheckAlarms(delta.get())); + + const Image *alarm = zone.AlarmImage(); + REQUIRE(alarm != nullptr); + + // Every pixel alarmed, so every pixel must be painted -- an outline would + // leave the interior black. + unsigned int painted = 0; + for (unsigned int y = 0; y < h; y++) { + const uint8_t *row = alarm->Buffer(0, y); + for (unsigned int x = 0; x < w; x++) + if (row[x * 3] || row[x * 3 + 1] || row[x * 3 + 2]) painted++; + } + CHECK(painted == w * h); + } +} + +// The highlight is built from a GRAY8 mask into a wider destination whose row +// stride is padded independently of the source's. Indexing the destination +// with the source's stride sheared the highlight diagonally across the frame +// and ran past the end of the destination on the last rows -- the same +// width-vs-linesize family as the striping fixes, but inside the highlight +// builder, where it stayed latent until the pixel check methods started using +// it. Run the same assertion at a padded width and an aligned one so a failure +// says whether the stride is to blame. +TEST_CASE("Image::BuildHighlight lands the highlight where the mask marked it", "[Image]") { + EnsureConfig(); + + auto check = [](unsigned int w, unsigned int h) { + Image mask(w, w, h, ZM_COLOUR_GRAY8, ZM_SUBPIX_ORDER_NONE); + memset(mask.Buffer(), 0, static_cast(w) * h); + + // One solid rectangle, well inside the frame. + const unsigned int r_lo_x = 100, r_hi_x = 199, r_lo_y = 50, r_hi_y = 149; + for (unsigned int y = r_lo_y; y <= r_hi_y; y++) + memset(mask.Buffer() + static_cast(y) * w + r_lo_x, 0xff, + r_hi_x - r_lo_x + 1); + + std::unique_ptr high(mask.BuildHighlight( + kAlarmColour, ZM_COLOUR_RGB24, ZM_SUBPIX_ORDER_RGB, nullptr, false)); + REQUIRE(high != nullptr); + + unsigned int painted = 0, first_y = h, last_y = 0, first_x = w, last_x = 0; + for (unsigned int y = 0; y < h; y++) { + const uint8_t *row = high->Buffer(0, y); + for (unsigned int x = 0; x < w; x++) { + if (row[x * 3] || row[x * 3 + 1] || row[x * 3 + 2]) { + painted++; + first_y = std::min(first_y, y); + last_y = std::max(last_y, y); + first_x = std::min(first_x, x); + last_x = std::max(last_x, x); + } + } + } + + INFO("width " << w << " mask linesize " << mask.LineSize() + << " highlight linesize " << high->LineSize()); + CHECK(painted == (r_hi_x - r_lo_x + 1) * (r_hi_y - r_lo_y + 1)); + CHECK(first_y == r_lo_y); + CHECK(last_y == r_hi_y); + CHECK(first_x == r_lo_x); + CHECK(last_x == r_hi_x); + }; + + SECTION("720 wide, GRAY8 row padded to 736") { check(720, 480); } + SECTION("640 wide, GRAY8 row already aligned") { check(640, 480); } +}