Playing a clip with cv2.VideoCapture in Jupyter works until you stop it: you press q, the cell finishes, and the frame window is still on the screen. Clicking its close button does nothing, or it takes the whole kernel down. The file, the decoder and the camera are all fine — this is a window-event problem with a precise cause and a three-line fix. This guide gives you that fix, a loop that closes its own window, a playback delay matched to the file's frame rate, and an inline display route that needs no native window at all.
The symptom, and what it is not
The window outlives the cell. The clip plays, q stops the loop, cv2.destroyWindow("frame") runs without an error, and the window stays. From that point the close button is dead, and on some machines the only way out looks like restarting the kernel.
Two things it is not. It is not the browser: the window is created by the kernel process, not by the browser tab, so switching browsers changes nothing — what differs between machines is the OpenCV build and its GUI backend. And it is not the image path: if the frames themselves show stripes or tearing, start with vertical lines in a camera image: the display path, not the sensor. Here the picture is perfect; only the window refuses to die.
Cause 1: destroyWindow runs after the last waitKey
cv2.waitKey does two jobs: it waits for a key, and it pumps the events of the native window backend — GTK or Qt on Linux, Cocoa on macOS, Win32 on Windows. Repaint, resize, close and destroy are all delivered through that pump. A destroy call marks the window for destruction; on the GTK and Cocoa backends the teardown itself is carried out by the event loop on a later spin.
Your loop calls cv2.waitKey on every iteration — until q breaks it. Then cv2.destroyWindow runs, the cell returns to the prompt, and no waitKey is ever called again: the marked window is never carried away. The Win32 backend usually removes the window on the spot, which is why the same notebook closes cleanly on one machine and hangs on another. The behavior has sat in the OpenCV issue tracker since 2015, and the HighGUI reference describes waitKey as the call that waits for a pressed key — the event pumping it also performs is the part everyone forgets.
Test it from a new cell:
import cv2
cv2.destroyAllWindows()
for _ in range(5):
cv2.waitKey(1)
The window disappears within those five spins — about 5 ms of pumping. If it does, cause 1 is confirmed, and the loop in the next section makes the fix permanent.
Cause 2: the kernel is still inside the loop
If the cleanup cell will not even start — it sits queued — the loop never exited. While a cell runs, the notebook queues everything else, and the window's close button sends an event your code is not reading.
The usual reason q never arrives is focus. waitKey reports keys only while the OpenCV window itself has keyboard focus, and in a notebook the focus sits in the browser. Test: interrupt the kernel from the Kernel menu, then run the cleanup cell above — that clears whatever an aborted loop left behind.
The loop that closes itself
Give the loop two exits, release the source, then destroy with a pump behind it. Variables carry their types and starting values:
import cv2
file_path: str = "clip.mp4" # your clip
window_name: str = "frame"
delay_ms: int = 33 # set from the fps formula below
cap = cv2.VideoCapture(file_path)
if not cap.isOpened():
raise RuntimeError(f"could not open {file_path}")
while True:
ret, frame = cap.read()
if not ret: # end of clip: rewind and loop
cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
continue
cv2.imshow(window_name, frame)
if cv2.waitKey(delay_ms) & 0xFF == ord("q"):
break # exit 1: the q key
if cv2.getWindowProperty(window_name, cv2.WND_PROP_VISIBLE) < 1:
break # exit 2: the close button
cap.release()
cv2.destroyAllWindows()
for _ in range(5):
cv2.waitKey(1) # the pump that finishes the teardown
print("capture open after release:", cap.isOpened())
cv2.getWindowProperty(window_name, cv2.WND_PROP_VISIBLE) reads 1.0 while the window exists and drops below 1 the moment the user closes it, so the loop ends by itself instead of leaving an orphan. raise RuntimeError beats sys.exit() in a cell: the failure is visible, and a SystemExit inside a notebook strands the loop's window behind it. The test is the print — False after cap.release() — and a window that is gone before the prompt returns.
Play the clip at its own frame rate
cv2.waitKey(delay) waits up to delay milliseconds for a key, and that delay is your frame period when nobody types. The common notebook snippet with delay set to 1 ms plays the clip as fast as the machine decodes it — a 30 fps clip flashes past in a fraction of its real length. Set it from the file instead:
Worked example, a 30 fps clip: 1000 ms ÷ 30 fps = 33.3 ms per frame, so delay_ms = 33 shows about 30 frames per second — real-time playback. If CAP_PROP_FPS returns 0 on an odd container, fall back to 33 ms and adjust by eye. And if a camera stream flatlines at 30 fps no matter what you set, that is the transport link rather than the window — see a camera stuck at 30 fps in a Python GenTL loop.
Show the frames inside the notebook
The native window is the wrong tool for a notebook anyway: it blocks the cell, steals focus and lives in the kernel's process. Draw the frames inline instead:
from IPython.display import clear_output
import cv2
import matplotlib.pyplot as plt
file_path: str = "clip.mp4"
cap = cv2.VideoCapture(file_path)
try:
while True:
ret, frame = cap.read()
if not ret:
break
clear_output(wait=True)
plt.axis("off")
plt.imshow(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
plt.show()
except KeyboardInterrupt:
pass
cap.release()
OpenCV stores BGR and matplotlib expects RGB, so the cv2.cvtColor call is not optional — skip it and your reds and blues swap. Stop the cell with the interrupt; there is no window to close and nothing leaks. Expect single-digit frames per second, because every frame is serialized to the browser as an image: this is an inspection view, not an operator display. For a real operator screen, use a proper GUI toolkit — displaying OpenCV video in a Tkinter window without flicker and camera live preview in GUI applications cover that, including controlling the display window's size and position.
On a server with no display of its own, imshow is not an option at all: headless OpenCV builds raise an error on it. A headless Linux vision server install walks through that set-up.
When the stream is a camera, not a file
cv2.VideoCapture accepts three kinds of source: a file path string, an integer index for a UVC device — index 0 is the first one — and an RTSP URL string. Industrial cameras do not appear in that list. A GigE Vision camera such as the MV-CU020-90GC is opened through its GenTL producer rather than through cv2.VideoCapture, and its frames land in the same kind of array that cap.read() returns. Everything in this guide — the waitKey pump, the two exits, the inline display — applies unchanged to that array.
For a GigE set-up the frames and the power reach the PC through the camera and its network interface:
- Sensor format
- 1/1.7"
- Lens mount
- C-mount
- Interface
- GigE
- Resolution
- Up to 3 MP
- Interface
- GigE
- Ports
- 2 ports
The MV-GE2002P is a dual-port PoE NIC: it supplies the camera and carries the frame data on the same cable, and you will find it with our frame grabbers and interface cards. To match a camera, lens and light to the station behind that stream, start the configurator at the camera step:
The configurator matches a camera, lens, light, light controller and cables to each other, step by step, and lists only parts that fit what you already picked.
Prevention checklist
Run through this before any notebook session that opens a window:
- End every session with
cv2.destroyAllWindows()followed by fivecv2.waitKey(1)calls — the destroy marks, the pump carries. - Give the loop two exits: the q key, and
cv2.getWindowProperty(window_name, cv2.WND_PROP_VISIBLE) < 1. - Call
cap.release()before the destroy block, so the file or device is closed first. - Click the video window once before pressing q; the notebook holds the keyboard focus otherwise.
- Raise an exception rather than calling
sys.exit()inside a cell. - Keep interactive window loops in a
.pyfile; keep the notebook for inline frames.
If the next thing behind that stream is a camera, the systems topic page gathers these set-up write-ups, and the contact page puts you in front of our applications team — expert support comes free with every order.
Questions
Why does the window close in a normal Python script but not in Jupyter?
When a script ends, the operating system removes every window that process created, so a missing pump goes unnoticed. A Jupyter kernel keeps running after the cell finishes, so the window it created stays until something pumps the backend's events. That is why the waitKey call scripts never needed becomes mandatory in a notebook.
Is cv2.destroyWindow or cv2.destroyAllWindows the right call?
cv2.destroyWindow(window_name) removes the one named window; cv2.destroyAllWindows() removes every window this kernel created. After an aborted cell you rarely know which windows survived, so destroyAllWindows is the safer call in a notebook. Both only mark windows for destruction, and both still need the five waitKey spins behind them.
How many waitKey calls does it take to close a stuck window?
Five calls of cv2.waitKey(1) is the working number — about 5 ms of event pumping. One often clears it on the Windows backend; GTK and Cocoa need a few spins. If the window is still there after ten, the loop is still running somewhere: interrupt the kernel first, then run the cleanup cell again.
Why does pressing q do nothing while the video plays?
waitKey delivers keys only to the window that holds keyboard focus, and in JupyterLab that is the browser until you click the video window. Click the frame once, then press q — lowercase, because ord('q') does not match a capital Q in the comparison.
Can I show the frames without any native OpenCV window?
Yes. Convert with cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) and draw with matplotlib's imshow; for motion, wrap the draw in IPython's clear_output(wait=True) and stop the cell with the interrupt. It runs at single-digit frames per second because every frame ships to the browser as an image — fine for inspecting frames, wrong for an operator view.

