System designTroubleshooting

VideoWriter writes an empty AVI file: feed it BGR frames

VideoWriter writes an empty AVI of a few kilobytes when it gets 4-channel BGRA frames. Convert each frame to 3-channel BGR with cvtColor, then write again.

LumaSpec Vision10 min read Share on LinkedIn

On this page 8 sections
  1. 01Why does VideoWriter write an empty AVI file with only a header?
  2. 02How do you test whether the frame type is the cause?
  3. 03What does the fix look like in code?
  4. 04What other causes leave an empty AVI file?
  5. 05How big should a healthy MJPEG recording be?
  6. 06When do camera SDKs hand you 4-channel buffers in the first place?
  7. 07How do you keep empty AVI files off your recording stations?
  8. 08Questions

The capture loop runs, nothing throws, and the AVI you asked for measures 5.7 KB — container header, no frames. The gap between a working preview window and an empty file almost always has one cause: cv::VideoWriter silently discards every frame that is not 8-bit, 3-channel BGR. GPU camera pipelines and many SDK buffers hand you four-channel BGRA instead, so every write() call lands in the void while the preview keeps showing pictures. Ten minutes of checking frame type and file size confirms it, and the fix is one cvtColor line.

Why does VideoWriter write an empty AVI file with only a header?

VideoWriter writes an empty AVI file because write() silently discards frames whose format does not match the encoder. A 4-channel CV_8UC4 BGRA buffer is dropped call after call without an error or exception, and only the container header — 5.7 KB in the case that surfaced this failure — reaches disk.

The writer's constructor did its job: it created the AVI container, opened the MJPEG encoder and wrote that header. Reports of the same symptom elsewhere land near 8 KB. isOpened() returned true, so your open-failure guard never fired.

The contract sits in the frame, not the writer. The input contract on OpenCV's VideoWriter reference page is an 8-bit, 3-channel frame — CV_8UC3. With the FFmpeg-backed AVI path, a CV_8UC4 BGRA buffer matches no accepted pixel format, so each write is skipped.

The preview hides it: imshow() renders 4-channel BGRA happily, so the display keeps updating while the file starves. Loop and preview see the same buffer and judge it differently — that asymmetry is why nobody notices until someone opens the file.

How do you test whether the frame type is the cause?

Print frame.type() immediately before write(): a failing BGRA buffer reports 24 (CV_8UC4, four 8-bit channels) and a valid BGR frame reports 16 (CV_8UC3), so one integer tells you which side of the contract the buffer is on.

std::printf("frame type=%d  %dx%d\n", imDisplay.type(), imDisplay.cols, imDisplay.rows);
// 24 = CV_8UC4 (BGRA): convert before write()
// 16 = CV_8UC3 (BGR):  write directly
type() value Macro Input format write() result
16 CV_8UC3 8-bit BGR encoded to disk
24 CV_8UC4 8-bit BGRA dropped silently

A 16-bit or floating-point buffer fails the same format check, so bring depth maps and float planes into 8-bit range before the writer sees them.

Isolate writer from input with a synthetic frame: build a cv::Mat probe(cv::Size(cols, rows), CV_8UC3, cv::Scalar(40, 40, 40)), write it a few times and release. If the file then grows to tens of kilobytes, the encoder works and the real buffer's format is the fault. If it stays at the header-only size even with valid BGR input, move to the codec checks below.

What does the fix look like in code?

Insert one conversion so every frame reaches write() as CV_8UC3: cvtColor(imDisplay, convertedFrame, cv::COLOR_BGRA2BGR), then videoWriter.write(convertedFrame) — that exact change took the failing capture from a header-only stub to a full recording.

cv::Mat convertedFrame;                                      // CV_8UC3, filled by cvtColor
cv::cvtColor(imDisplay, convertedFrame, cv::COLOR_BGRA2BGR); // 4 channels -> 3
videoWriter.write(convertedFrame);                           // now reaches the encoder

The Python call is the same shape: cv2.cvtColor(frame, cv2.COLOR_BGRA2BGR) before writer.write(frame).

COLOR_BGRA2BGR drops the alpha channel and keeps size and values, so the opened cv::Size still matches. The conversion costs one pass over the frame — about 3.7 MB read and 2.8 MB written per 720p frame, roughly 97 MB/s of memory traffic at 15 fps, which is noise next to the disk write that follows.

On OpenCV 2.x builds the codec tag reads CV_FOURCC('M','J','P','G'); OpenCV 3 and 4 spell the same tag cv::VideoWriter::fourcc('M','J','P','G'). The tag was never the bug here, but record it correctly when you do touch it.

What other causes leave an empty AVI file?

After channel count, the likely causes in order are a codec the local backend cannot encode (H264 without an FFmpeg or GStreamer build), a frame size that does not match the cv::Size you opened with, and a writer that crashed or was killed before release() closed the file.

  1. Codec without a backend. XVID, DIVX, H264 and MP4V need the local FFmpeg or GStreamer libraries; on a lean embedded build they open to nothing or fail outright. Test: open with MJPEG and the .avi container, which needs no external encoder.
  2. Frame size mismatch. The writer opened at one cv::Size and the loop delivers another — a crop, a resize, a different stream mode. Some backends skip mismatched frames as quietly as mismatched formats. Test: compare imDisplay.cols and .rows against the opened size during bring-up.
  3. Open failure never checked. If isOpened() is false, every write() is a no-op. Test: check it immediately after construction and abort with the filename in the message.
  4. Unclean exit. A crash or a killed process before release() leaves a header with nothing behind it. Fix: scope the writer so release() runs on every exit path, and treat a header-only file after a crash as a symptom of the crash, not of the codec.

In the original failure, all four alternative codecs were tried and the FFmpeg development libraries were installed — the file still held nothing but its header, because the format, not the codec, was rejecting frames.

Where H264 is a hard requirement, route frames through a GStreamer pipeline: an appsrc feeding BGR frames into an encoder and a muxer writes a normal file. Recording-side faults beyond these — frame gaps, timing drift — have their own pages on the systems topic page.

How big should a healthy MJPEG recording be?

A healthy 60-second MJPEG capture at 1280×720 and 15 fps lands between roughly 125 and 250 MB; the failed capture stays at 5.7 KB, which makes file size your pass/fail gauge.

bytes per frame (CV_8UC3) = width × height × 3 raw stream rate = bytes per frame × fps MJPEG file size ≈ raw stream rate × duration ÷ compression ratio

Worked example, one minute of recording:

  • 1280 × 720 = 921,600 pixels per frame
  • 921,600 × 3 bytes = 2,764,800 B ≈ 2.76 MB of BGR data per frame
  • 2.76 MB × 15 fps ≈ 41.5 MB/s of raw stream
  • 41.5 MB/s × 60 s ≈ 2.49 GB uncompressed — the ceiling
  • JPEG at usable quality compresses about 10:1 to 20:1, so the encoder lands near 125-250 MB per minute; even a conservative 30:1 leaves about 83 MB

The stub measures roughly 400,000 times below the raw ceiling, so a healthy file is not marginally bigger than a broken one. Poll the file size during capture: if it has not grown within ten seconds of the loop starting, stop and fix the format.

The 15 fps in the math is the rate you passed to the constructor, not the rate the camera delivered. Measure the real frame rate from timestamps before trusting duration math; under long exposure a GigE stream can fall well below its nominal rate, which is exactly what frame rate drops at longer exposure times explains. The same arithmetic scales up: the 2000 fps recording data-rate plan runs it at high-speed numbers.

When do camera SDKs hand you 4-channel buffers in the first place?

GPU pipelines pad images to four bytes per pixel for aligned memory access: a 1280×720 BGRA buffer occupies 3,686,400 bytes against 2,764,800 for BGR, a 33% premium that buys the GPU faster copies and hands you exactly the CV_8UC4 format the writer refuses.

The padding follows the buffer home. When you copy device memory to the host, declare the destination Mat CV_8UC4 and carry the row pitch through the copy — a strided 2D device-to-host transfer takes the source pitch and destination step separately, and mixing them up shears the image diagonally. Stereo and depth SDKs are the classic source: their GPU-side images are commonly BGRA, while depth measurements arrive as separate 16-bit or float planes.

Two neighbouring failures have their own pages: wrapping an SDK frame buffer in a cv::Mat without an extra copy covers the stride arithmetic, and copying stereo frames out of the SDK buffer before saving them covers the host-side copy that must finish before the buffer is reused.

How do you keep empty AVI files off your recording stations?

Make three checks permanent in the recording path: the frame's type() equals 16 (CV_8UC3), isOpened() is true before the loop, and the file grows past a megabyte within the first ten seconds — a station that passes all three cannot ship a header-only stub silently.

Hard-wire them into the loop:

  • Before the loop — open with MJPEG in an .avi container, check isOpened(), log the opened cv::Size.
  • At stream start — log type() and convert with COLOR_BGRA2BGR whenever the buffer reports 24.
  • During capture — poll the file size every few seconds and compare delivered frames against elapsed time × fps; recording that outruns real time is its own timing fault with a known fix.
  • At the end — call release() on every exit path, then read the file back with cv::VideoCapture and count frames against the fps × duration target.
  • On the station — size storage to the measured rate, not the uncompressed math: one minute at 125-250 MB means 2-4 MB/s, or 7-15 GB per hour of continuous footage.

The same loop records inspection footage from standard cameras onto a vision controller. Either interface — GigE area scan cameras or their USB3 siblings — feeds the writer identically once the SDK has delivered its host buffer, and the area scan camera finder filters by interface, resolution and shutter.

Area scan camerasHikrobot MV-CU020-90GC 2.5 MP Area Scan Camera, GigE, GMAX4002, ColorMV-CU020-90GC
Sensor format
1/1.7"
Lens mount
C-mount
$309.00View part
Area scan camerasHikrobot MV-CT0MU0060/R/NN/NN 6 MP Area Scan Camera, 3rd Gen., USB3.0, Mono, BASEMV-CT0MU0060/R/NN/NN
Sensor format
1/1.8"
Lens mount
C-mount
$969.00View part

A 2.5 MP GigE model covers line-side recording at modest bandwidth; the 6 MP USB3 model buys more detail per frame at the same recording loop.

The host decides how much footage survives. The VC2100 controller below pairs its SSD with a 1 TB hard drive, and at the 2-4 MB/s MJPEG rate above that drive holds roughly 70-140 hours of continuous footage.

Vision controllersHikrobot MV-VC2140-128G40-1T VC2100 Vision Controller, N97, 8GB+128G SSD+ 1THDDMV-VC2140-128G40-1T
LAN ports
3-4 LAN ports
USB 3 ports
2 USB 3 ports
Light outputs
4 built-in channels
Memory
8 GB
$1,679.00View partBuild a system with it

Stations recording several GigE cameras into one box hit the LAN port count first; the VC3000 controller below carries 5+ LAN ports for exactly that.

Vision controllersHikrobot MV-VC3301H-128G66 I3-8100, 8GB+128G SSD, No PoE, PCIe expansion supported, Incl. VM6200Pro licenseMV-VC3301H-128G66
LAN ports
5+ LAN ports
USB 3 ports
3-4 USB 3 ports
Light outputs
4 built-in channels
Memory
8 GB
$2,419.00View partBuild a system with it

Multi-camera disk planning is its own arithmetic — the multi-camera recording page walks through budgets that keep every stream on disk without drops.

If a station still writes stubs after these checks, send the frame type, the FOURCC tag and the file size through the contact page, or configure the camera side if you are specifying hardware from scratch.

Questions

Does the 4-channel rule also apply to 16-bit or float frames?

The contract for write() is 8-bit data, so CV_16U depth buffers and float planes fail the same check. Bring them into range first: convertTo with a scale factor maps 16-bit values onto 8-bit, and a depth map can be normalized before it reaches the writer. The channel rule stays the same — three channels, CV_8UC3 — whatever the source buffer was.

Why does isOpened() return true while every write() call is dropped?

The constructor only proves the container and encoder started: the AVI header is written at open, which is exactly the few kilobytes left on disk. The per-frame format check runs inside write(), and with a 4-channel input it fails without an exception on the FFmpeg AVI path. Treat isOpened() as necessary, never as proof that frames are landing — file size is that proof.

Which FOURCC and container should I pick on a controller without FFmpeg?

MJPEG with the .avi container is the default that opens almost everywhere, and it produced full recordings in the original failure once frames were converted to BGR. H264, MP4V and XVID depend on the local FFmpeg or GStreamer build; without a working backend they open to nothing. Where H264 is required, feed a GStreamer appsrc pipeline that hands BGR frames to the encoder and muxer.

How do I check a finished file without a media player?

Read it back with cv::VideoCapture and count frames: CAP_PROP_FRAME_COUNT should report about 900 for a 60-second clip recorded at 15 fps, while a stub reports 0 or refuses to open. Compare the count against delivered frames rather than the nominal rate — if it comes up short, measure the real frame rate from timestamps before blaming the writer.

How much disk does continuous 720p MJPEG recording fill per day?

At the worked rate of 125-250 MB per minute, continuous 1280×720 recording at 15 fps fills 180-360 GB per day. Triggered capture scales with duty cycle: a line that records 20% of the time writes roughly 36-72 GB per day. Size the drive for the worst case, and keep the header-only stub in mind as the signature of a writer, not a disk, failing.