System designHow-to

Display OpenCV video in a Tkinter window without flicker

Stop blank-frame flicker when you display OpenCV video in a Tkinter window: one after() loop, one canvas item, a PhotoImage reference that stays alive.

LumaSpec Vision10 min read Share on LinkedIn

On this page 9 sections
  1. 01Why the canvas flickers
  2. 02What you need
  3. 03The pattern: one after() loop, one canvas item, one kept reference
  4. 04Worked example: timing a 30 fps, 4000 × 3000 USB3 stream on a 640 × 480 canvas
  5. 05Keep every Tk call on the main thread
  6. 06Mistakes that bring the flicker back
  7. 07Verify the flicker is gone
  8. 08When a canvas preview is the right amount of tool
  9. 09Questions

cv2.imshow plays a smooth stream, but it opens its own grey window and fights your layout for focus. Routing the same frames into a Tkinter canvas is the standard build for a machine-vision operator panel: the video sits in a corner of your own UI, next to buttons, results and machine status. The moment you do that, most code starts blinking — the canvas paints its background for an instant between frames. That flicker is not a rendering setting you can tune away; it is a dropped PhotoImage reference, usually combined with Tk calls from a capture thread. This guide gives you the loop pattern that holds a steady 30 fps preview, the threading rules behind it, the timing arithmetic for your camera, and the checks that prove the blink is gone.

Why the canvas flickers

Two faults produce the same symptom, and most flickering preview code has both.

The first is a reference that dies. Tk renders an image through an ImageTk.PhotoImage object, and the Tk-side image lives only as long as that Python object does. When Python garbage-collects a PhotoImage — for example one created in a local variable by a function that has returned — the underlying Tk image is destroyed, and anything still pointing at it shows nothing. A loop that builds the PhotoImage into a local name, points the canvas at it and moves on drops the object at the end of every iteration. Tk then paints the canvas background until the next frame arrives, and that background paint is your blink. canvas.itemconfig() does not rescue it: re-pointing a kept item at a destroyed image produces the same blank.

The second is the wrong thread. Tkinter runs a single-threaded event loop, and its documented threading model allows only the thread that created the widgets to call them. A capture thread that constructs PhotoImage objects or calls canvas.itemconfig() races that loop. The visible result is intermittent blanking; the worst case is a hard crash that lands in a different place on every run.

What you need

  • Python 3 with opencv-python and Pillow installed; Pillow supplies the ImageTk module that turns NumPy arrays into Tk images.
  • One frame source producing 8-bit BGR NumPy arrays: a webcam through cv2.VideoCapture(0), or frames grabbed through an industrial camera's SDK into the same array shape.
  • The source's width and height, read once from cv2.CAP_PROP_FRAME_WIDTH and cv2.CAP_PROP_FRAME_HEIGHT.
  • A 640 × 480 canvas for the preview — this guide's example size; change the canvas and the resize target together.
  • One process doing capture and display, with root.mainloop() running in the main thread.

The worked example below is sized for a USB3 camera stream of 30 fps at 4000 × 3000:

Area scan cameras12 MP USB3 area scan camera, IMX226, 1/1.7" rolling shutter, 30 fps, 4000 × 3000 resolution (LS-CMR-0166)LS-CMR-0166
Sensor format
1/1.7"
Interface
USB3
Resolution
12 MP
Shutter
Rolling
$560.00View partBuild a system with it

A webcam needs no driver work; an industrial camera feeds the same loop through its SDK, with the SDK's grab call replacing cv2.VideoCapture.read() in the pattern. The area scan cameras category is where to pick such a camera.

The pattern: one after() loop, one canvas item, one kept reference

The fix is one loop on Tk's own clock that never lets the image reference go.

  1. Create tk.Tk() and the tk.Canvas once, on the main thread, before anything else touches Tk. The example UI is 640 × 480.
  2. Give the panel two attributes that outlive a single frame: self.photo, initialized to None, holds the current ImageTk.PhotoImage; self.item, initialized to None, holds the integer item id that canvas.create_image returns. These two references are the entire fix.
  3. Open cv2.VideoCapture once and read frames inside the update method, not in a separate loop; keep cv2.imshow and cv2.waitKey out of the process entirely.
  4. In each update, resize the frame to the canvas size, convert BGR to RGB with cv2.cvtColor and cv2.COLOR_BGR2RGB, build the PhotoImage and store it in self.photo. On the first frame, call create_image at (0, 0) with anchor='nw' and save the id it returns; on every later frame, call itemconfig on that one id.
  5. Schedule the next update with root.after(33, self.update) — 1000 ms ÷ 30 fps — and let mainloop() drive everything. No time.sleep: it blocks the event loop that redraws the canvas.
import tkinter as tk
import cv2
from PIL import Image, ImageTk

class CameraPanel:
    def __init__(self, root, video_source=0):
        self.root = root            # tk.Tk — only this thread may touch Tk
        self.canvas = tk.Canvas(root, width=640, height=480, bg='black')
        self.canvas.pack()
        self.photo = None           # ImageTk.PhotoImage — kept alive between frames
        self.item = None            # int canvas item id, returned by create_image
        self.delay_ms = 33          # 1000 ms / 30 fps
        self.cap = cv2.VideoCapture(video_source)
        self.root.after(self.delay_ms, self.update)

    def update(self):
        ret, frame = self.cap.read()   # uint8 NumPy array, BGR
        if ret:
            small = cv2.resize(frame, (640, 480))
            rgb = cv2.cvtColor(small, cv2.COLOR_BGR2RGB)
            self.photo = ImageTk.PhotoImage(image=Image.fromarray(rgb))
            if self.item is None:
                self.item = self.canvas.create_image(0, 0, image=self.photo, anchor='nw')
            else:
                self.canvas.itemconfig(self.item, image=self.photo)
        self.root.after(self.delay_ms, self.update)

root = tk.Tk()
CameraPanel(root, 0)
root.mainloop()

Everything in the listing except self.photo, self.item and self.delay_ms is a documented Tkinter, Pillow or OpenCV name: cv2.VideoCapture.read, cv2.resize, cv2.cvtColor, Image.fromarray, ImageTk.PhotoImage, canvas.create_image, canvas.itemconfig and root.after. Keep the OpenCV VideoCapture reference open while you adapt it, and check the Pillow ImageTk and Tkinter canvas pages in their official documentation for the call signatures.

Worked example: timing a 30 fps, 4000 × 3000 USB3 stream on a 640 × 480 canvas

The inputs, and where each comes from:

  • 30 fps and 4000 × 3000 pixels — the cadence and resolution the data sheet prints for the USB3 camera in this guide (LS-CMR-0166).
  • 640 × 480 pixels — the canvas size in the pattern above.
  • 3 bytes per pixel — an 8-bit BGR or RGB frame.

The rule: schedule the next update 1000 ÷ fps milliseconds after the current one, and convert only the pixels the canvas can show.

delay_ms = 1000 / fps frame_bytes = width × height × 3 rate = frame_bytes × fps
  • Schedule: 1000 ms ÷ 30 fps = 33.3 ms, so after(33, update) re-runs just inside the camera cadence.
  • Full frame: 4000 × 3000 px × 3 B = 36,000,000 B = 36 MB per frame; at 30 fps that is 36 MB × 30 = 1080 MB/s pushed through resize, cvtColor and the PhotoImage build.
  • Canvas share: 640 × 480 = 307,200 px against 12,000,000 sensor px, and 12,000,000 ÷ 307,200 = 39.1 — the canvas shows about 1/39 of the frame.
  • Resized frame: 640 × 480 px × 3 B = 921,600 B ≈ 0.92 MB per frame; at 30 fps that is 0.92 MB × 30 = 27.6 MB/s, 39× less than the full-frame path.

Result: after(33) with a resize to 640 × 480 holds the camera's 30 fps while moving 0.92 MB per frame instead of 36 MB.

Decision: resize before you convert whenever the canvas is smaller than the sensor, and scale the delay to the source. The work scales with area: a 1280 × 960 canvas holds 1,228,800 px, exactly 4 × the 640 × 480 copy, so doubling each canvas edge quadruples the per-frame conversion. If the task needs full-resolution pixels in the window, drop the schedule instead: 1000 ms ÷ 15 fps = 66.7 ms gives after(66, update), and the full-frame path still moves 36 MB × 15 = 540 MB/s — convert on a worker thread and keep only the PhotoImage and itemconfig on the main thread.

The same arithmetic fits any cadence: a 9 fps GigE stream gives 1000 ÷ 9 = 111 ms per update. For a networked preview at that pace, the GigE model in this guide is the pick:

Area scan cameras12 MP GigE area scan camera, IMX226, 1/1.7" rolling shutter, 9 fps (LS-CMR-0053)LS-CMR-0053
Sensor format
1/1.7"
Lens mount
C-mount
Interface
GigE
Resolution
12 MP
$560.00View partBuild a system with it

Keep every Tk call on the main thread

When cap.read() blocks longer than the display interval, the single loop stutters, because grab and redraw share one thread. Split them at the right line: OpenCV on the worker, Tk on the main thread.

import queue

frames = queue.Queue(maxsize=1)   # holds at most one NumPy RGB frame

def grab():                       # worker thread: OpenCV only
    while True:
        ret, frame = cap.read()
        if not ret:
            break
        rgb = cv2.cvtColor(cv2.resize(frame, (640, 480)), cv2.COLOR_BGR2RGB)
        if frames.full():         # discard the stale frame first
            frames.get_nowait()
        frames.put_nowait(rgb)

The after() loop on the main thread drains the queue and runs the same four display lines as before: frames.get_nowait(), Image.fromarray, ImageTk.PhotoImage, canvas.itemconfig. With maxsize=1 and put_nowait, the queue carries the newest frame, so a busy UI shows current state instead of a backlog.

The boundary is exact: PhotoImage construction, create_image, itemconfig and root.after are Tk calls and stay on the main thread; NumPy and OpenCV work can run anywhere. Run one after() loop and one writer thread, nothing more. When the symptom is a frozen GUI rather than a blink, that is the fetch-threading failure covered in GUI freezes during image capture: run fetch off the main thread; the trigger-driven grab loop is laid out in hardware trigger grab loop in Python: acquire and save each frame.

Mistakes that bring the flicker back

  • A PhotoImage built into a local name, even inside an after()-driven update: Python drops it when the method returns and Tk clears the image. Keep it in self.photo.
  • create_image on every frame: 30 new canvas items per second at 30 fps, and once the shared image variable is reassigned, every earlier item renders blank. One item, one itemconfig.
  • Tk calls from the capture thread: they can pass a hundred test runs and still crash on the production line.
  • cv2.imshow or cv2.waitKey left in the process: that runs a second windowing event system against Tk. Delete both once the canvas works.
  • Converting before resizing: cvtColor on the full 4000 × 3000 frame costs 39× the pass of cvtColor on the 640 × 480 copy in this guide's example. Resize the NumPy array first.
  • Building a PhotoImage before tk.Tk() exists: ImageTk needs a default root, so a module-level image created at import time fails. Create the root, then the images.
  • The same reference rule on labels: label.configure(image=img) blinks the same way when nothing else holds img.

Verify the flicker is gone

  • One item, always: after a minute of running, len(self.canvas.find_all()) returns 1. If it grows, something still calls create_image.
  • The rate you scheduled: count update() calls for 5 s and divide — 150 ÷ 5 s = 30 updates per second against a 33 ms schedule.
  • A high-contrast scene: a dark part moving across a white background shows a cleared image instantly, so run a minute of that before calling it fixed.
  • Rate below the camera's? Do not read the target from CAP_PROP_FPS, which some sources report as 0; time the loop instead, and Video FPS returns zero in OpenCV: read frame rate reliably covers that zero reading.
  • A window that freezes wholesale is a different fault — blocking the main thread — and the wider preview patterns sit in Camera live preview in GUI applications.

When a canvas preview is the right amount of tool

A preview canvas shows an operator what the camera sees; it is not the acquisition path. Keep hardware-triggered capture, recording and multi-camera pipelines out of the display thread, and show the preview as a resized copy of what those paths produce. Holding every frame while the UI samples is its own problem — High speed camera recording without dropped frames covers the capture side.

In hardware terms, this loop, the camera SDK and the UI live in one process on one machine. An Intel Core i3 vision controller with 8 GB of memory and five or more LAN ports, such as the MV-VC3301H-128G66, is built for exactly that single-box job:

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

Next step: pick the camera with the camera finder, then build the capture side in the configurator, and browse more capture-and-display patterns on the systems topic page. If this GUI is one piece of a larger inspection cell, describe the cell through the contact page and we will size the capture side with you.

Questions

Why does my Tkinter PhotoImage disappear when the function that made it returns?

The Python object is the only thing keeping the Tk image alive. When the function returns, Python garbage-collects the PhotoImage and Tk clears the image, so the canvas item points at nothing until the next frame builds a new one. Store the object where it outlives the function: an attribute such as self.photo, or a module-level variable. A label configured with label.configure(image=img) needs the same kept reference — the widget does not hold it for you.

Can I construct the ImageTk.PhotoImage in a worker thread and only call itemconfig on the main thread?

No. Build the PhotoImage on the main thread too. The safe split is: the worker does the OpenCV work — read, resize, cvtColor — and puts the finished NumPy array into a queue.Queue(maxsize=1); the after() loop drains it, constructs the PhotoImage and updates the canvas. With maxsize=1 the queue always holds the newest frame, so a slow UI shows current state instead of building a backlog.

What after() delay should I use for a 30 fps camera?

1000 ms ÷ 30 fps = 33.3 ms, so schedule after(33, update). Then measure what you actually get: 150 update calls in 5 s is 30 updates per second, which matches the schedule. Do not take the target rate from CAP_PROP_FPS alone — some sources report 0 there. If the measured rate falls short, the per-frame work is too heavy: resize to the canvas size before building the PhotoImage.

Does this pattern work with an industrial camera instead of a webcam?

Yes — once frames arrive as NumPy arrays, the display path is identical. Replace cv2.VideoCapture.read() with the camera SDK's grab call, keep the resize and the BGR-to-RGB conversion, and the single-item itemconfig loop carries over unchanged. Keep the grab on a worker thread when the SDK call blocks longer than the display interval, and hold the queue at maxsize=1 so the preview always shows the newest frame delivered.