Both functions initialised p_pixfmt before the setjmp() that installs the
libjpeg error recovery point, and only read it afterwards. GCC warns that
a longjmp() out of zm_jpeg_error_exit could restore a stale register copy:
warning: variable 'p_pixfmt' might be clobbered by 'longjmp' or 'vfork'
[-Wclobbered]
Move the initialisation below the setjmp() block instead of marking the
variable volatile. Nothing between the old and new positions uses the
value and the error paths do not reference it, so behaviour is unchanged
and no live value crosses the jump.
This unblocks builds using ENABLE_WERROR.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A monitor whose source signals full range (Dahua h264) decodes to
AV_PIX_FMT_YUVJ420P, and Monitor passes that format through to shared memory
unchanged. When zms scales such a frame for montage or console thumbnails,
SWScale::Convert mapped only the input format through fix_deprecated_pix_fmt()
and handed the still-deprecated YUVJ format to swscale as the destination, so
libswscale logged
deprecated pixel format used, make sure you did set range correctly
for every context it built. Watch view at 100% scale never hit it because
Image::Scale returns early when the dimensions already match.
Map deprecated formats on both sides in SWScale::Convert (both the buffer and
the AVFrame overload), in Image::Assign(AVFrame*), in
Monitor::setupConvertContext and in the LocalCamera conversion context.
Mapping the destination YUVJ format to its non-J equivalent makes swscale
default that side to limited range, which would compress full-range output.
Replace zm_sws_set_input_range() with zm_sws_set_ranges(), which takes the
original pre-fix formats for both sides and sets srcRange/dstRange accordingly.
Image::Assign(AVFrame*) now compares the mapped source and destination formats,
so a YUVJ420P frame into a YUVJ420P image takes the av_image_copy fast path
instead of running through swscale.
Image::Scale built a fresh SWScale, and therefore a fresh sws context, on every
call. That is a full sws_init_context per scaled frame per stream, and it is
what turned the warning into a per-frame flood rather than a one-off. Reuse a
thread_local SWScale; sws_getCachedContext re-inits itself when the geometry
changes.
Tests: zm_swscale_range.cpp installs an av_log callback and asserts that
SWScale::Convert with a YUVJ destination and Image::Scale on a YUVJ420P image
emit no deprecated-format message, plus a luma check that the range handling
still survives the mapping.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NDhTBPj9xEaT52pRmAufaP
Since decoder-native passthrough, packet->image is typically YUV420P even
when the monitor's configured Colours is RGB, so the zone alarm highlight
(built from the configured colours) arrives at Image::Overlay() as
RGB24/RGB32 while the analysis image is planar YUV420. The dispatch keyed
on zm_bytes_per_pixel(target)==1, which conflates GRAY8 with planar YUV,
so the combination fell into the Colourise() branches: Colourise() warned
'Target image is already colourised, colours: 1' and returned, after which
the overlay loop walked the planar YUV buffer as packed RGB, corrupting
the saved analysis frames.
Gate the Colourise() branches to true GRAY8 targets and add a
YUV420-target branch that converts only the marked (non-black) highlight
pixels to YUV - luma plus the shared 2x2 chroma sample - honouring the
highlight's subpixel order.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoNiNxwgyaHV29CbiueCUf
On suspend-resume Monitor::CheckAction() called ref_image.DumpImgBuffer()
on the capture thread while the analysis thread was using ref_image in
DetectMotion() -> Image::Delta(). DumpImgBuffer() frees the buffer and nulls
the pointer but leaves width/height/colours intact, so Delta's "different
sized images" guard still passed and the delta helper walked from a null
base pointer, faulting at address 0 on the first row. That is the reported
"Fault address: (nil)" inside the gray8 delta helper. Portrait monitors were
not special; they just made the resume-during-analysis window easy to hit.
Move the reference-image reset onto the thread that owns ref_image:
CheckAction() sets an atomic ref_image_reset_ flag and Analyse() performs
the DumpImgBuffer() itself on its next pass, so the buffer is never freed
out from under an in-flight Delta/Blend. Reject a null buffer in Image::Delta
as a defensive backstop.
Add tests/zm_zone_stride.cpp: a regression test that reproduced the null
delta crash, plus zone coverage at portrait (1080x1920, stride-padded) and
landscape dimensions through the alarmed-pixel and blob paths.
fixes#4983
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KQipwf632JGgNH4W7p8cqs
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>
Image::Assign(const AVFrame*) passed the raw decoded frame format straight
to sws_getCachedContext. When the frame is a deprecated full-range JPEG
format (YUVJ420P etc, typical of MJPEG-decoded frames), swscale logs
"deprecated pixel format used, make sure you did set range correctly",
seen from nph-zms. Run the source format through fix_deprecated_pix_fmt()
before it reaches swscale, matching what SWScale::Convert already does.
Doing it before the identity-copy check also lets a YUVJ420P->YUV420P
same-size conversion take the av_image_copy fast path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
get_y_image() wraps the decoder Y plane zero-copy but recorded
FFALIGN(width,32) as the Image stride instead of the frame's real
linesize[0]. For widths that are not a multiple of 32 the decoder packs
the plane tighter than FFALIGN, so:
- Image::Rotate/Flip re-derived the source stride via
av_image_fill_arrays(...,32) and read past the end of the borrowed
plane (and skewed Y-channel motion analysis, which reads the same
buffer).
- Image::Flip sized its destination from this->size, which is tight for a
borrowed plane and smaller than the 32-aligned layout the planes are
written at, overrunning the destination.
Record the frame's real linesize in get_y_image(); use the Image's own
linesize for the source plane-0 stride in Rotate/Flip; size Flip's
destination from av_image_get_buffer_size(...,32). All are no-ops for
self-consistent ZM-allocated (32-aligned) images.
tests/zm_image_linesize.cpp: Rotate 90/180/270 and Flip H/V over a tight,
non-32-aligned GRAY8 source verifying correct output.
refs #4788
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SWScale::Convert chose av_image_fill_arrays alignment by heuristic
(width % 32 ? 1 : 32) for both buffers. Image buffers are always laid
out align-32, so any width not divisible by 32 made Convert read luma
rows 16 bytes short and chroma planes from packed offsets: diagonal
shear plus garbage chroma. Rotating a monitor is what produces such
widths (1280x720 ROTATE_270 -> 720 wide, 3840x2160 ROTATE_90 -> 2160
wide, both % 32 == 16), so every scaled view and every re-encode of a
rotated monitor was corrupted while unrotated monitors (1280/2688/3840
all % 32 == 0) were untouched. The rotate/flip segfault fix exposed
this: before it, rotated planar frames crashed zmc before reaching
Scale.
Alignment is a fact about how a buffer was laid out, not something
derivable from dimensions. Convert now takes explicit in/out alignment:
Image::Scale passes 32/32, the videostore encode path mirrors
get_out_frame's allocation choice, libvnc passes 1 (packed VNC
framebuffer) and 32 (Image WriteBuffer). Remove the unused
SetDefaults/ConvertDefaults API rather than threading alignments
through dead code.
Tests: new Scale regression case on a 720x1280 YUV420P image
(column-banded luma, uniform chroma) fails before the fix exactly as
observed live (sheared rows, V plane reading 0) and passes after.
Full suite 84/84.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Image::Rotate and Image::Flip computed chroma plane dimensions with
AV_CEIL_RSHIFT(width, log2_chroma_w). The macro's runtime form is
-((-(a)) >> (b)), which relies on arithmetic right shift of a negative
value and is only valid for signed operands - FFmpeg always passes int.
Image::width/height are unsigned, so the negation wraps, the shift is
logical, and the result is 2^31 + ceil(w/2^b) instead of ceil(w/2^b):
for 1280x720 the chroma rotate received src_w=2147484288 and
src_h=2147484008 (captured in gdb), writing gigabytes out of bounds.
Effect: zmc's decoder thread segfaulted on the first decoded frame of
any monitor with a rotated or flipped orientation and a planar pixel
format - a monitor with decoding Always crash-loops.
Replace the macro at both sites with an explicit unsigned ceiling
shift helper and a comment documenting the trap.
Tests: new tests/zm_image.cpp covers Rotate 90/180/270 and Flip on
YUV420P with per-plane marker pixels, plus odd dimensions exercising
the chroma ceiling. The Rotate cases segfault before this fix (verified,
exit 139) and pass after. Full suite 85/85 via ctest. Live-verified on
a 1280x720 ROTATE_270 monitor with decoding Always: pre-fix crash
within seconds, post-fix 90s clean run under gdb.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Image::Colourise sizing-failure Error: av_image_get_buffer_size /
av_image_get_linesize return negative for AV_PIX_FMT_NONE and
unrecognised formats, so this branch fires exactly when
av_get_pix_fmt_name(p_req_pixfmt) can return nullptr. Switched to
zm_get_pix_fmt_name.
- Image::Deinterlace_4Field fallback Panic: same issue — fires when
imagePixFormat is outside our dispatch set, where av_get_pix_fmt_name
may return nullptr. Switched to zm_get_pix_fmt_name.
refs #4788
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Image::Delta: pass delta8_bgr/rgb/argb/abgr/bgra/rgba/gray8 directly to
delta_row() instead of dereferencing with *. They're already function
pointers; the deref is redundant and would be undefined behaviour if
any pointer were null (Initialise() sets them, but the explicit *
obscures the intent and adds nothing).
- Switched the Delta fallback Panic from av_get_pix_fmt_name() to the
nullptr-safe zm_get_pix_fmt_name() wrapper. The Panic fires exactly
when imagePixFormat is unrecognised, where the raw function would
return nullptr.
refs #4788
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The y += 2 loop ran while `y < height`, but copied even row y into odd
row y+1. For odd image heights, the final iteration had y == height-1
and wrote to row height — past the end of the image buffer in all three
colour branches (GRAY8/YUV-Y plane, RGB24, RGB32).
Stop one short of the last row (y < height - 1) so y+1 stays in
bounds; the orphan last row in odd-height images is left untouched
(consistent with the (even, odd) pairing this discard pass is doing).
Also added a guard for height < 2 to keep the unsigned `height - 1`
calculation from wrapping.
refs #4788
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Monitor::GetImage and Monitor::getSnapshot bounds-check branches were
logging "Image Buffer has not been allocated", but by the time control
reaches them the empty-vector check has already passed — the buffer IS
allocated, the index is just out of range. Replaced with explicit
"index N out of range (image_buffer.size() = M)" so debugging starts
from the right place.
- Image::WriteBuffer unsupported-format error only printed `colours`,
but the mapping in zm_pixformat_from_colours depends on both `colours`
and `subpixelorder`, and planar formats legitimately have colours=1.
Print both fields so a bad (colours, subpixelorder) pair is
immediately identifiable.
refs #4788
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Image(const AVFrame*) constructor only initialised blend_buffer_
and blend_buffer_size_ before calling AssignDirect(frame). AssignDirect
now calls DumpImgBuffer() on both the failure (invalidate) and success
paths to release any previously-owned buffer — but in the
freshly-constructed Image, buffer/buffertype/allocation are
uninitialised, so DumpImgBuffer would read garbage and potentially
free an invalid pointer.
Use constructor delegation so Image() runs first (zero-initialises
every member, sets buffertype = DONTFREE), making the subsequent
DumpImgBuffer call a no-op on a known-empty Image.
refs #4788
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Image::Assign(const Image&): when the source linesize was *smaller*
than the destination's (e.g. src came from a held-buffer ctor with a
packed device stride while dst is FFALIGN'd to 32 from
av_image_get_buffer_size), the function fell through to the flat
memcpy of `size` bytes — over-reading the source by
(linesize - image.linesize) * height bytes. Undefined behaviour;
could crash or copy unrelated memory. Generalised the per-row branch
to handle both directions: trigger whenever linesize != image.linesize
and copy min(src, dst) bytes per row, so neither buffer is read or
written past its capacity. Planar formats still refuse explicitly
since per-row would lose chroma; the error message now reads "vs"
instead of ">" to match the broader condition.
- Added an explicit `#include <libavutil/pixdesc.h>` in zm_image.cpp.
The recent Rotate/Flip rewrites call av_pix_fmt_desc_get /
av_pix_fmt_count_planes / use AVPixFmtDescriptor, which previously
only compiled thanks to transitive includes from zm_ffmpeg.h →
libavutil/imgutils.h. Make the dependency explicit.
refs #4788
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The raw-buffer Assign overload previously validated buffer_size against
av_image_get_buffer_size(..., align=32) and then memcpy'd `size` bytes
flat. But callers feed it packed source buffers — Camera::ImageSize() is
now derived with align=1 to describe device buffers (V4L2 mmap, raw RTP,
etc.) — so for non-32-aligned widths:
* the size check spuriously rejected valid packed source buffers, and
* if relaxed, the flat memcpy would have read past the source.
Validate the source size against the packed (align=1) layout and the
destination size/allocation against the aligned (align=32) layout, then
copy plane-by-plane via av_image_copy. av_image_fill_arrays gives us
both layouts' per-plane pointers and strides, and av_image_copy walks
each plane with its own src/dst linesize so per-row padding never
over-reads the source.
refs #4788
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The destination format for the same-format fast path was derived via the
deprecated AVPixFormat() getter, which re-derives from the legacy
(colours, subpixelorder) pair. If those fields ever drift out of sync
with imagePixFormat — or hit the GRAY8/YUV420P alias collision — the
fast path would compare against the wrong target and either fall
through to sws_scale unnecessarily or, worse, av_image_copy the wrong
layout. Use imagePixFormat directly and drop the manual
av_get_pix_fmt_name nullptr dance in the Debug log in favour of the
zm_get_pix_fmt_name wrapper.
refs #4788
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Image::AssignDirect(const AVFrame*): both the invalidate() failure
path and the success path overwrote buffer/buffertype without freeing
any previously-owned buffer. If the Image currently owned a
ZM_BUFTYPE_ZM/MALLOC/NEW allocation, that memory was leaked on every
AssignDirect(frame) call. Added DumpImgBuffer() at the top of
invalidate() and immediately before the success-path buffer
reassignment; DumpBuffer is a no-op for DONTFREE buffers, so this is
safe whether the Image previously owned its memory or wrapped a
caller's buffer.
- Switched five remaining av_get_pix_fmt_name() calls flagged by review
to zm_get_pix_fmt_name():
* Monitor::GetAlarmImage warnings (zm_monitor.cpp:1432, 1438-1439)
* Monitor::ReadShmFrame warnings (zm_monitor.cpp:3042, 3049-3050)
* Camera ctor fallback Error (zm_camera.cpp:90)
All can be reached with AV_PIX_FMT_NONE or unrecognised formats where
av_get_pix_fmt_name returns nullptr, which is undefined when fed to %s.
refs #4788
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Monitor::WriteShmFrame: image_pixelformats[index] was being recorded
even when image_buffer[index]->Assign(*capture_image) silently left
the slot untouched (Assign returns void; held-buffer undersize and
unknown src format both early-return without mutating the dest).
Readers would then adopt the newly-published format while the slot
still held the previous frame's bytes, producing garble. Check the
dest's post-Assign PixFormat against the source's and only publish on
match; warn and keep the previously-published format otherwise so
readers continue interpreting the slot bytes correctly.
- Monitor::WriteAlarmImage: same fix for alarm_image — verify Assign
adopted src.PixFormat() before publishing *alarm_image_pixelformat.
- Image::AVPixFormat(AVPixelFormat) setter: switched the four
format-name logs to the nullptr-safe zm_get_pix_fmt_name wrapper.
The first Error branch triggers exactly when new_pixelformat is
unrecognised, and av_get_pix_fmt_name returns nullptr for those.
- monitor.php: removed the manual translate fallback for
DeprecatedColoursSetting. web/includes/lang.php always merges
en_gb.php as a fallback when the active language differs, so
translate() never returns the bare key once en_gb.php defines it.
refs #4788
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Image::Overlay (no-offset variant): rewrite all eight format branches
to walk row-by-row using each image's own linesize. The previous
linear walk with `buffer + size` as the end pointer drifted across
rows whenever this->linesize != image.linesize (which can happen for
images constructed via held-buffer ctors at a different stride) and
also walked into the destination's chroma planes for planar YUV
destinations whose `size` covers chroma. Inner loops bound by `width`
per row in every branch, so per-row padding is left untouched.
- Monitor::connect: align the image_pixelformats SHM base address up
to alignof(AVPixelFormat) before casting. shared_images +
2*image_buffer_count*image_size can be misaligned when image_size
isn't a multiple of alignof(AVPixelFormat) (e.g. GRAY8 with odd
width sourced from camera->ImageSize() before the SHM upper-bound
applies). An unaligned AVPixelFormat* is undefined behaviour on
strict-alignment ISAs and slow even on x86. The +64-byte padding
reserved in mem_size already covers the small shift.
refs #4788
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The delta8_* and *_deinterlace_4field_* SIMD helpers process pixel runs
or 2D blocks without any concept of per-row stride. With the
FFALIGN(linesize, 32) layout, an Image's buffer may have per-row padding
(linesize > width*bpp), and feeding the helpers the raw buffer pointer
made them treat padding bytes as image data — wrong motion deltas and
visibly broken deinterlacing on non-32-aligned widths.
- Image::Delta: drive each delta8_* helper one row at a time, passing
`width` pixels per call with `buffer + y*src_linesize`,
`image.buffer + y*img_linesize`, and `pdiff + y*dst_linesize`. No
copies; just a stride-aware caller-side loop.
- Image::Deinterlace_4Field: the 4-field helpers internally use the
passed `width` as their row stride, so a per-row loop wouldn't
preserve their inter-row processing. Pack both inputs into
tightly-laid-out (linesize == width*bpp) temp buffers, run the
helper, then copy only the data bytes back into the padded source
buffer (padding stays intact). Fast path skips the copy when linesize
already equals width*bpp on both images (the 32-aligned-width case).
refs #4788
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Image::Assign(AVFrame*) fast path: PopulateFrame can fail
(av_buffer_create / av_image_fill_arrays errors); calling av_image_copy
on an unpopulated temp_frame is undefined. Check the return and bail
out cleanly on failure.
- Image::Scale: use the canonical imagePixFormat directly instead of
the deprecated AVPixFormat() getter (which re-derives via the legacy
(colours, subpixelorder) pair and would hit the GRAY8/YUV420P alias
collision if those drift).
refs #4788
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Image::DeColourise: updating only imagePixFormat=GRAY8 left size still
at the previous RGB sizing and linesize at the previous RGB stride.
Downstream ops that allocate via size (Flip/Rotate) under-allocate,
and row-stride loops that use linesize address the wrong rows. Now
recomputes size and linesize from the new GRAY8 layout via
av_image_get_buffer_size/av_image_get_linesize and calls
update_function_pointers().
- Image::AssignDirect(AVFrame*): never called update_function_pointers()
after the format change, so an Image that previously held a different
format kept stale fptr_delta/fptr_blend/fptr_convert bindings and
subsequent ops took the wrong optimized path. Added the call at the
end of the success path.
refs #4788
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Image::Rotate / Image::Flip: chroma plane dimensions were computed as
`width >> log2_chroma_w` / `height >> log2_chroma_h`, which floors.
For odd luma widths/heights the last chroma column/row was skipped,
leaving U/V samples unrotated/unflipped on odd-sized frames. Use
AV_CEIL_RSHIFT to match FFmpeg's plane dimension convention.
- Image::Assign(const Image&): the AV_PIX_FMT_NONE guard's error
message said "unexpected colours per pixel" and printed image.colours,
which is misleading — planar formats legitimately have colours==1.
Report the real cause (missing AVPixelFormat metadata) plus the
legacy (colours, subpixelorder) for context.
refs #4788
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Image::HighlightEdges: the RGB24 and RGB32 output branches addressed
the destination buffer as `high_buff + ((y*linesize + lo_x) * 3|4)`,
where `linesize` is the *source* (GRAY8) stride. For non-32-aligned
widths the source GRAY8 stride and destination RGB24/RGB32 stride
differ in their padding, so multiplying the source stride by 3 or 4
doesn't yield the destination's row offset and writes spill past
high_buff on the last row. Read the destination's linesize from
high_image and use that for phigh. The neighbour-pixel lookups also
used `p ± width`; switched to `p ± src_linesize` so we follow the
actual source row stride. All three branches (GRAY8/RGB24/RGB32) now
pick neighbours via src_linesize.
- Image::MaskPrivacy: chroma plane dimensions were `width/2` and
`height/2` (truncating). For odd width/height the planar layout in
AVFrame uses ceil(W/2) / ceil(H/2), so the last column or row of
chroma was never neutralised, leaving a strip with the source's
original hue along the right/bottom edge — defeats the privacy mask.
Use (W+1)/2 and (H+1)/2; existing in-loop guards on the Y indices
already clamp the odd-edge lookup to the bitmap bounds.
refs #4788
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six format-dispatch fallbacks were still printing only the legacy
`colours` value, but the dispatch in each spot is now keyed off
pixelFormat/imagePixFormat. With the GRAY8/YUV420P alias collision
(both colours=1) the legacy value frequently doesn't identify the true
format, so a "Unexpected colours: 1" log on a misroute is useless.
Updated each Panic to include the AVPixelFormat enum value and its
human-readable name (via av_get_pix_fmt_name), plus the legacy
(colours, subpixelorder) for context:
- RemoteCameraRtsp constructor
- FfmpegCamera constructor
- VncCamera constructor
- LocalCamera conversion-selection branch
- VideoStream::SetupCodec mpeg helper
- Image::Delta unknown-format fallback
refs #4788
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Image::Overlay(image, lo_x, lo_y): rebase source and destination
pointers per row using each image's own linesize. The previous
sequential psrc++ drifted across rows whenever linesize >
width*colours (i.e. non-32-aligned widths).
- Image::Rotate / Image::Flip: rewrite as plane-aware via
av_image_fill_arrays. Each plane is rotated/flipped independently
using its own dimensions and stride, so planar YUV (YUV420P/J420P
and YUV422P/J422P for 180/hflip/vflip; YUV420P/J420P additionally
for 90/270) no longer loses chroma. YUV422P 90/270 is explicitly
refused — the chroma subsampling would transpose to vertical-only,
which isn't the same AVPixelFormat. Packed RGB24/RGB32/GRAY8 go
through the same helper with bpp set per format. Inner helpers
use linesize for stride, so non-32-aligned widths no longer drift
into per-row padding.
- Image::AssignDirect(AVFrame*): derive the wrapped size from the
AVFrame's own linesize[] via av_image_fill_pointers rather than
av_image_get_buffer_size(..., 32) — decoder-allocated frames may
have a different alignment, so the 32-aligned size could overstate
the actual buffer. Also sanity-check that data[1]/data[2]/data[3]
are contiguous from data[0] (a non-contiguous AVFrame can't be
wrapped with a single Image::buffer pointer). Both failure paths
fully invalidate the Image (the existing helper).
refs #4788
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Continuation of the FFALIGN(linesize, 32) consumer fixes. Alignment is
kept for SIMD performance; only the addressing in consumers is fixed.
- Image::Outline: both branches (dx>=dy and dx<dy, all three colour
paths) now compute pixel addresses as buffer + y*linesize + x*colours
instead of width-based offsets, so lines aren't drawn into per-row
padding for non-32-aligned widths.
- Image::Fill(Rgb, density, Polygon): the scan-line polygon filler
computes each scanline base as buffer + scan_line*linesize + lo_x*bpp
in all three colour branches.
- Image::Deinterlace_Linear / Deinterlace_Blend /
Deinterlace_Blend_CustomRatio: all three colour branches address rows
by y*linesize (instead of y*width, y*width*3, y*width<<2), so they
blend the correct rows for non-32-aligned widths. Inner loops still
process exactly `width` pixels per row; per-row padding is untouched.
- Image::MaskPrivacy: after masking the Y plane, also neutralise the
chroma (U/V) samples covering masked pixels for planar YUV formats
(YUV420P/J420P/YUV422P/J422P). Previously, masking only the Y plane
left chroma carrying the original hue, so source colour bled through
the privacy mask — a real privacy leak when the capture format is
planar YUV. Conservative rule: if any covered Y bit is masked, set
the chroma sample to 128 (neutral). Plane pointers/strides come from
av_image_fill_arrays so the math works for any aligned plane layout.
refs #4788
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When av_image_get_buffer_size or zm_colours_from_pixformat rejects the
AVFrame's format, AssignDirect(AVFrame) previously cleared
size/linesize/colours but left width/height assigned and buffer pointing
into frame->data[0]. A caller that ignored the error log could still
walk width*height pixels through that pointer — and once the AVFrame is
freed the buffer dangles. Clear width/height/pixels/buffer too so the
Image is unambiguously empty after a failed assign. Reorder validation
to run before any state mutation so a successful assign also leaves
every field consistent in a single step.
refs #4788
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The FFALIGN(linesize, 32) introduced by the AVPixelFormat migration adds
per-row padding for non-32-aligned widths. Functions that walked pixel
buffers using width*bytes_per_pixel as the row stride wrote into padding,
shifted rows sideways, or under-allocated output buffers for those
widths. Convert each affected pixel op to use the Image's linesize as
the per-row stride. Alignment is kept for SIMD/perf; only the consumer
addressing is fixed.
- Image::MaskPrivacy: reset the row pointer to `buffer + y*linesize`
each iteration instead of monotonically incrementing across rows.
- Image::Annotate (GRAY8/RGB24/RGB32 branches): use linesize for the
per-row advance and char-block reset. The RGB32 branch additionally
uses `linesize / sizeof(Rgb)` as the Rgb-typed stride.
- Image::Fill and Image::Fill(colour, density, ...): compute each row's
base address as `buffer + y*linesize + lo_x*colours` instead of
`colours*(y*width + lo_x)`.
- Image::Colourise: allocate the output buffer with
av_image_get_buffer_size(target_fmt, w, h, 32) and copy row by row
using src_linesize / dst_linesize. The previous tightly-packed
allocation undersized the buffer (so AssignDirect would reject it)
and smeared pixels across row boundaries for non-aligned widths.
- Image::Deinterlace_Discard (all three colour branches): index source
and destination rows by y*linesize instead of (y*width)*bytes.
- Monitor::CheckSignal: convert the random linear index to (x, y) and
address as y*LineSize() + x*bytes_per_pixel, so samples don't read
per-row padding or land on the wrong row.
- Monitor::Capture signal-loss path: rewrote the publish-order comment
so it matches the actual code (WriteShmFrame, then timestamp, then
last_write_time, then last_write_index as the commit step).
refs #4788
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Image::Assign(raw buffer overload): derive new_size from
av_image_get_buffer_size(pix_fmt, w, h, 32) instead of
width*height*colours, which undercounts planar formats (YUV420P/J420P/
YUV422P/J422P) and would silently truncate U/V planes. Update linesize
in the same branch so LineSize() consumers (JPEG encode, overlay) stay
in sync with the new format. Reject AV_PIX_FMT_NONE and negative
av_image_get_buffer_size returns up-front.
- Image::AVPixFormat(AVPixelFormat) setter: validate the requested
format via zm_colours_from_pixformat() and check both av_image_*
return values before mutating any state. On failure, leave
imagePixFormat/size/linesize untouched so callers can recover instead
of inheriting wrapped-unsigned junk.
- Image::Assign(const Image&) stride-mismatch branch: refuse planar
formats (YUV420P/J420P/YUV422P/J422P) explicitly. The per-row copy
only touches the Y plane; doing it silently on planar input leaves
U/V uninitialised in the destination and produces solid-green output
downstream. Caller must reconvert or reallocate.
- Monitor::connect: size SHM slots to an upper bound across every
supported AVPixelFormat (RGBA at w*h, align=32) instead of the
monitor's configured camera->ImageSize(). Prevents "Held buffer is
undersized" failures when the no-conversion pipeline transports a
format larger than the legacy DB Colours selection (e.g. YUV422P or
RGBA into a GRAY8-configured monitor). Store the chosen capacity on
the Monitor as shm_slot_size.
- Monitor::ReadShmFrame: validate image_pixelformats[index] both as a
recognised enum AND that its av_image_get_buffer_size fits
shm_slot_size before calling AVPixFormat(fmt). Treats a corrupted or
torn SHM value as ignorable instead of letting it overrun the held
slot. Single zm_colours_from_pixformat() call (no longer duplicated).
- web/.../monitor.php: inline fallback for
translate('DeprecatedColoursSetting') so non-en_gb locales render an
English string instead of the literal key name.
refs #4788
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Image::Overlay: in the 1-byte-per-pixel branch, bound the loop by
std::min(height*linesize, image.height*image.linesize) instead of
`buffer + size`. For planar YUV destinations `size` includes chroma
planes, while GRAY8 sources have only Y bytes; the old loop read past
image.buffer and clobbered the destination's chroma. Restricting to the
smaller of the two Y-plane spans keeps the overlay in the luma plane
and never over-reads the source.
- Monitor::ReadShmFrame: image_pixelformats[] lives in SHM and is written
by another process, so its value must be treated as untrusted. Validate
via zm_colours_from_pixformat() before passing into AVPixFormat(fmt) —
an unsupported enum value would make av_image_get_buffer_size return
negative and wrap into the Image's unsigned size/linesize members.
Compare against PixFormat() (canonical imagePixFormat) instead of the
deprecated AVPixFormat() getter.
- Monitor::Capture signal-loss path: reorder SHM publishing so the slot
bytes and per-slot shared_timestamps[index] are written first, then
last_write_time is set from packet->timestamp (the fresh value, not
the stale tv_sec the slot held from its previous occupant), and
last_write_index is assigned last as the commit step. Readers gate on
last_write_index, so all per-slot state must be visible before that
final assignment.
refs #4788
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Image::Assign: when source linesize > destination linesize, copy only
the destination's row capacity per line; previously copied
image.linesize bytes into rows of smaller linesize, overflowing the
destination buffer on the last row.
- Monitor::CheckSignal: dispatch the 1-byte-per-pixel branch via
zm_bytes_per_pixel(pix_fmt) == 1 so YUV422P/J422P are sampled on the
Y plane like GRAY8/YUV420P, instead of falling through and reporting
"no signal".
- Monitor::WriteShmFrame: record image_pixelformats[index] via
capture_image->PixFormat() (canonical imagePixFormat) instead of
AVPixFormat(), which re-derives from the deprecated
(colours, subpixelorder) pair and could propagate stale metadata.
- Monitor::connect: rewrite stale comment that claimed readers don't
need per-slot pixformat adoption; readers MUST call ReadShmFrame()
to adopt the actual format zmc wrote.
- Camera::Camera: guard linesize/imagesize derivation against
AV_PIX_FMT_NONE and negative returns from av_image_get_linesize /
av_image_get_buffer_size, falling back to width * colours stride so
unsigned wrap-around can't break SHM sizing.
refs #4788
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Several Image methods were silently producing wrong output for planar
YUV formats and for any format-width pair where the natural linesize
isn't a multiple of 32. Each surfaced as a different visual artefact
once the SHM started carrying YUV420P/RGB32/etc. through to the
JPEG encoders without an upfront convert-to-RGB32 step:
- Image::Assign(const Image &): sized new_size from
image.height * image.linesize, which counts only the Y plane for
planar layouts. Use image.size, which is populated from
av_image_get_buffer_size and includes chroma. Without this an
Assign'd YUV420P image left Cb/Cr at zero — solid green output via
YCbCr-to-RGB later.
- Image::AssignDirect(width,height,colours,...): computed
new_buffer_size as W*H*p_colours and linesize as W*p_colours, both
wrong for planar (1.5x undersize) and for non-32-aligned RGB widths
(the actual buffer stride after sws_scale + av_image_fill_arrays
with align=32 is FFALIGN(W*bpp, 32), not W*bpp). Use
av_image_get_buffer_size and FFALIGN(av_image_get_linesize(...), 32)
so the recorded size/linesize match the real buffer layout.
Mismatched linesize produced the diagonal-shift artefact in
RGB32 streams at scaled widths like 1094.
- Image::WriteBuffer: same FFALIGN linesize fix; it was using the
unaligned natural linesize too.
- Image::Scale(new_width, new_height): scale_buffer was sized
(new_W+1)*(new_H+1)*colours which undercounts planar formats.
SWScale::Convert correctly checks the buffer against
av_image_get_buffer_size and returns an error, but the caller
ignored the return value and AssignDirect'd the empty/uninit
buffer anyway. Size with av_image_get_buffer_size, check the
Convert return, free and bail on failure.
- Image::Scale(factor): the hand-rolled pixel-doubling/decimation
loop treated the buffer as packed `colours`-bytes-per-pixel —
scaled only the Y plane and dropped chroma. Drop the loop and
delegate to Scale(new_width, new_height), which now uses sws_scale
for all formats.
- Image::EncodeJpeg + Image::WriteJpeg: format dispatch and
scanline writer had no planar-YUV support. WriteJpeg's existing
branch unpacked the buffer as packed YUYV 4:2:2 with offset
scanline*W*2, which read W*H/2 bytes past the end of any YUV420P
buffer — a latent crash that became reachable once image_buffer
could carry YUV420P data, segfaulting from the Event thread on
every event-frame write. Add a planar branch that uses
av_image_fill_arrays for plane pointers and per-plane linesizes
and feeds JCS_YCbCr scanlines built from the Y/U/V planes —
works for both 4:2:0 (YUV420P/YUVJ420P) and 4:2:2 (YUV422P/YUVJ422P).
- Image::Overlay: the warning fired on a benign GRAY8-on-YUV420P
case (both report colours=1 due to the GRAY8/YUV420P alias
collision in zm_rgb.h, but their imagePixFormat differs). Reframe
the check so it only warns when imagePixFormat actually matches
but the ZM (colours, subpixelorder) metadata diverges — i.e. a
real format-tracking bug.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eight Copilot comments from the second review pass:
1. monitor.php (#3): "Deprecated - will be auto-detected..." note next
to TargetColorspace bypassed translate(). Added DeprecatedColoursSetting
key to en_gb and routed the deprecation note through translate() so it
localises with the rest of the form.
2. tests/zm_pixformat.cpp (#4): zm_colours_from_pixformat / round-trip
tests didn't cover the new YUV422P/YUVJ422P entries (added by
02e6be6b4). Added explicit assertions in both test cases — bumps
pixformat coverage from 105 to 115 assertions.
3. zm_image.cpp WriteBuffer (#5): linesize and size were derived from
p_width * p_colours, which undercounts planar YUV* (where p_colours=1
via the GRAY8 alias collision but actual buffer needs ~1.5x/2x for
chroma). Use av_image_get_buffer_size and av_image_get_linesize for
the AVPixelFormat instead, with bail-out on either failing.
4. zm_image.cpp AssignDirect (#6, #7): av_image_get_buffer_size returns
int and can be negative; assigning that into unsigned size/allocation
wrapped to a huge value. Check the return first, treat negative as the
same "unsupported format" failure as zm_colours_from_pixformat
returning false, and reset size/allocation/linesize/pixels to 0
(alongside imagePixFormat=NONE/colours=0/subpixelorder=0) so the
Image is left in a single coherent invalid state instead of partially
stale.
5. zm_image.cpp Assign (#8): av_get_pix_fmt_name(format) can return
nullptr (e.g. AV_PIX_FMT_NONE / unknown); passing that into
Debug(..., "%s", ...) would segfault. Capture once with a fallback
string before logging.
6. zm_monitor.cpp Capture path (#9): same nullptr issue with two Debug
calls — capture native_fmt_name once with fallback.
7. zm_monitor.cpp can_passthrough comment (#10): comment claimed
YUVJ422P would be converted to YUV420P because Image drops chroma,
but can_passthrough now allows YUV422P/YUVJ422P passthrough since
02e6be6b4 added 4:2:2 support. Updated the comment to describe the
current behavior (full 4:2:0 + 4:2:2 planar passthrough plus GRAY8
and RGB24/32) so the code and the rationale agree.
Tests: 76 cases, 788 assertions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Image::Overlay() warned when (colours == image.colours &&
subpixelorder != image.subpixelorder), which made sense when
(colours, subpixelorder) was the canonical format identifier.
Now that imagePixFormat is canonical, the check produces false
positives in a normal code path: zm_monitor.cpp's analysis pass calls
analysis_image->Overlay(*(zone.AlarmImage())) where the destination is
YUV420P (colours=1 via the GRAY8/YUV420P=1 alias collision in
zm_rgb.h, subpixelorder=ZM_SUBPIX_ORDER_YUV420P=11) and the source is
the zone's GRAY8 alarm mask (colours=1, subpixelorder=NONE=2). The
overlay dispatch below already handles this correctly via
zm_bytes_per_pixel(imagePixFormat) == 1 on both sides — only the Y
plane of the dest is touched, leaving chroma untouched, which is
exactly the intent. The warning was just noise.
Reframe the check around imagePixFormat: warn only when the
AVPixelFormat actually matches but the ZM (colours, subpixelorder)
metadata diverges, which would indicate a real format-tracking bug.
The new message also names the AVPixelFormat for context, instead of
two opaque integers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues flagged by Copilot review on the AVPixelFormat-migration PR,
plus one build fix that was needed to land them:
1. zm_local_camera.cpp set subpixelorder to BGR for V4L2_PIX_FMT_RGB24
captures. V4L2_PIX_FMT_RGB24 is byte-order R,G,B in memory and is
mapped to AV_PIX_FMT_RGB24 by getFfPixFormatFromV4lPalette earlier
in the same file, so the matching ZM subpixel order is RGB. Setting
BGR meant red and blue were swapped in the captured image whenever
a V4L2 camera was configured with the RGB24 palette. Long-standing
bug — preserved unchanged through the AVPixelFormat migration —
now fixed to ZM_SUBPIX_ORDER_RGB.
2. Image::AssignDirect(const AVFrame*) called zm_colours_from_pixformat
without checking the return value, leaving colours/subpixelorder at
their previous values for any unsupported AVPixelFormat. Wrap the
call and on failure put the Image into an explicit invalid state
(AV_PIX_FMT_NONE plus zeroed colours/subpixelorder) so the
inconsistency surfaces immediately instead of producing wrong-format
reads downstream.
3. Drop the u_buffer = ... / v_buffer = ... assignments inside
Image::Assign()'s identity-copy path. Those members exist on the
ai_server lineage but not on master, so the PR branch did not
compile against master as-is. av_image_copy reads the planes
directly out of temp_frame->data, so the assignments were not
load-bearing — they look like leftover state-tracking that didn't
survive the upstreaming. Comment notes why the lines were removed.
Tests pass: 76 cases, 778 assertions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Use av_image_copy instead of sws_scale when source and dest format +
dimensions match. Eliminates unnecessary per-frame pixel processing
for passthrough formats (e.g. YUVJ422P from MJPEG cameras).
refs #4735
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The migration from colours==ZM_COLOUR_GRAY8 to imagePixFormat==GRAY8
broke YUV420P handling: the old check matched both GRAY8 and YUV420P
(both had colours==1) but the new check only matched GRAY8.
For pixel-rendering operations (Annotate, Fill, Outline, DrawLine,
Rotate, Flip, Delta, MaskPrivacy, Deinterlace, Overlay) that work on
the Y-plane of any 1-byte-per-pixel format, replace the GRAY8-only
check with zm_bytes_per_pixel(imagePixFormat)==1. This covers GRAY8,
YUV420P, YUVJ420P, YUV422P, and YUVJ422P.
Format-identification sites (JPEG encoding, Colourise, DeColourise)
that genuinely distinguish grayscale from YUV are left unchanged.
refs #4735
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
After migrating format dispatch from colours to imagePixFormat,
several methods that update colours/subpixelorder were not also
updating imagePixFormat, leaving it stale. This caused format
misidentification downstream — e.g. DecodeJpeg falling back to
RGB24 while imagePixFormat still claimed RGBA, producing vertical
lines and washed-out colors in the live stream.
Fix WriteBuffer, Assign(buffer), Assign(Image), AssignDirect(buffer),
and AssignDirect(AVFrame) to keep imagePixFormat in sync.
refs #4735
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ZM_COLOUR_GRAY8, ZM_COLOUR_YUV420P, and ZM_COLOUR_YUVJ420P were all
defined to 1, making format identification via colours ambiguous.
LocalCamera misidentified YUV420P as GRAY8, causing V4L2 MJPEG cameras
to decode to grayscale via expensive sws_scale conversion.
Replace the legacy ZM_COLOUR_*/ZM_SUBPIX_ORDER_* integer pair with
AVPixelFormat as the single source of truth for pixel format dispatch:
- Add src/zm_pixformat.h with central format helpers:
zm_pixformat_from_colours, zm_colours_from_pixformat,
zm_bytes_per_pixel, zm_db_colours_to_pixformat, zm_is_rgb32,
zm_is_rgb24, zm_is_yuv420
- Add AVPixelFormat pixelFormat member + PixelFormat() accessor to Camera
- Add PixFormat() accessor to Image, delegate AVPixFormat methods
to shared helpers
- Migrate all ~100 format dispatch comparisons in zm_image.cpp,
zm_local_camera.cpp, zm_ffmpeg_camera.cpp, zm_remote_camera_rtsp.cpp,
zm_libvlc_camera.cpp, zm_libvnc_camera.cpp, zm_monitor.cpp,
zm_mpeg.cpp from colours/subpixelorder checks to imagePixFormat/
AVPixelFormat checks
- Deprecate GetFFMPEGPixelFormat, delegate to zm_pixformat_from_colours
- Fix DeColourise bug: imagePixFormat was not updated to GRAY8
- Deprecate ZM_COLOUR_* and ZM_SUBPIX_ORDER_* constants in zm_rgb.h
- Add deprecation notice on Monitor.Colours web UI dropdown
- Add 13 Catch2 test cases (105 assertions) for format mapping helpers
refs #4735
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Image::Fill(Polygon) implements scan-line polygon fill but iterated
through active edges one at a time instead of in pairs. For convex
polygons (always exactly 2 active edges per scan line) this happened
to work, but for non-convex polygons it would fill the gaps between
concave sections.
A banana-shaped zone, for example, would have its inner concave area
incorrectly marked as inside the zone, causing motion detection to
trigger on the area the user explicitly drew the zone to avoid.
Fix by stepping the iterator by 2 to fill between pairs of edges
following the standard parity rule for scan-line polygon fill.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add www-data to video and dialout groups in Debian/Ubuntu postinst
scripts so zmc can access /dev/video* devices on fresh installs.
RedHat packaging already handled this via gpasswd in %post.
Add compile-time ZM_FONTDIR to zm_config_data.h.in and use it as a
fallback in zm_image.cpp when the configured font_file_location is not
found, fixing font loading failures caused by stale DB config values.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
colours and subpixelorder were set to GRAY8/NONE before the
format detection logic, so RGB32 SSSE3/fast paths were never
reached and subpixel order switches always hit the default case.
Save originals before overwriting and set output values after
conversion.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace per-frame AllocBuffer/free cycle with a persistent
blend_buffer_ member that is allocated once and reused across
calls. For non-holdbuffer images, swap buffer pointers instead
of freeing and reallocating. Eliminates ~8MB alloc+free per
frame for 1080p RGBA.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Merge() reset pdest and psrc to buffer base on every pixel iteration,
so only buffer[0] was ever written. Move pdest outside the loop and
use direct array indexing for source buffers.
Highlight() had the same pointer reset bug plus iterated over size
(total bytes) instead of pixels, and the unsigned diff calculation
wrapped around instead of computing absolute difference. Fix pointer
management, loop bound, and diff comparison.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>