Building a Vision Inspection App with PySide6 and OpenCV
ALGORITHM
What an inspection screen beside the line needs is five parameters, a result overlay, and one line of parameters saved in the same file as the verdict.
Building an inspection UI of about 220 lines with PySide6 and OpenCV and running it on a synthetic image, I checked the 60 μm defect rule, a QImage conversion trap and a debounce wiring mistake in numbers.
About 13 min read
Defect detection algorithms are usually born in a script on a laptop or in a Jupyter cell. Numbers such as a threshold of 25 or a 31 px kernel sit in the middle of the code, and results are checked on an image popped up in a single window. At a developer’s desk, that is enough. The moment it moves beside the line, things change.
Changing a parameter means opening the code, so adjustment on the floor stops until a developer arrives. Nothing records who judged with which values and when, so when overdetection or missed detection comes back, there is no way to trace the cause. An inspector that cannot be adjusted keeps running with a loose threshold, and the smallest defects are the first to slip through that gap. The synthetic image used here puts a left-to-right brightness gradient and sensor noise on a surface that scatters light evenly, like matte molded plastic, so its conditions are milder than real images of tricky reflective parts.
The fix I recommend is a small skeleton, not a big inspection package. Pull the inspection logic out into a pure function1, and put only parameter input, a result overlay, the verdict and a CSV log on top of it. Build this skeleton in about 220 lines of PySide6 and OpenCV and actually run it, and three points turn up that have to be checked in numbers.
The inspection logic lives outside the screen
I do not put the inspection logic inside the UI class. It lives on its own in a function, inspect(), that takes an image and parameters and returns only results. Once the logic is mixed into screen code, the same verdict cannot be reproduced in a batch recheck or a unit test, and switching UI frameworks means rewriting the algorithm too. Kept as a function, it can be checked first, without any screen, for whether the same input gives the same output.
The example’s inspect() runs in four steps. A median blur2 with a kernel larger than the defects builds a background that keeps only the lighting shading, the difference from that background is taken according to defect polarity, the result is binarized with a gray-level threshold, and after 8-connected component labeling3 a minimum-length filter is applied. The minimum length is converted as minimum defect ÷ pixel resolution4: 60 μm ÷ 20 μm/px = 3 px. On the synthetic test image (1,024 × 768 px), the function keeps two defects, a scratch and a spot, and filters out one speck that passed the threshold but is only 2 × 2 px.
The background kernel size is worth checking in numbers too. Shrinking the kernel from 31 px to 15 px leaves the result unchanged, but at 11 px, the same as the spot diameter, the scratch area is trimmed from 1,435 px to 1,264 px, at 9 px the defects break into six pieces, and at 5 px only two 3 px fragments remain. A kernel smaller than the defect lets the median blur swallow the defect into the background. My rule is an odd kernel clearly larger than the widest defect.
3px
Minimum length the size filter keeps: 60 μm ÷ 20 μm/px
11px
Widest defect the background kernel must exceed (spot diameter)
8.3×
Threshold of 25 gray levels ÷ synthetic noise σ of 3 levels
All three numbers trace back to μm and gray levels. That is why the parameters are entered in μm instead of px. When the lens or the FOV changes, fixing the one resolution value keeps the defect criterion intact, and the numbers on screen mesh with the numbers in the spec sheet in the same units.
Only the numbers the algorithm uses go on screen
The screen holds only the five inputs the algorithm actually uses (resolution, minimum defect, background kernel, threshold, polarity), the verdict, the results table and CSV saving. Limiting the inputs to algorithm parameters maps every value on screen one to one onto a cause of the verdict. The more options the algorithm does not use pile up on screen, the harder it becomes to trace what changed when a result shifts.

On the screen, read the verdict box at the upper right together with the info line below it. The verdict is ‘NG, 2 defects’, and the info line carries the minimum length of 3.0 px along with a note that one of three components was dropped for size. The overlay paints kept defects red and components that passed the threshold but were removed by the size filter blue, so the screen separates what went unseen from what was discarded for being small. The lengths of 5,300 μm and 220 μm in the results table are the bounding-box long sides of 265 px and 11 px multiplied by 20 μm/px.
Rechecking on every spin of a spin box repeats the inspection for every intermediate value passed through during continuous input. So the example uses a debounce5: a 150 ms single-shot timer gathers the input and inspects only once, with the last value. One line connecting signal and slot6 is the trap here. Connect the spin box’s valueChanged(int) straight to QTimer.start, and the new value is passed as the argument of start(msec), so the moment the threshold is set to 40, the debounce interval changes from 150 ms to 40 ms. The example connects through a lambda that discards the argument and keeps the interval.
The basis of a verdict has to stay both on screen and in the file. Before any result rows, the CSV records one line of resolution, minimum defect, kernel, threshold and polarity. I weigh that one line more heavily than the screen. When overdetection or missed detection returns, being able to check ‘what was the threshold then’ first is what lets a change in lighting be told apart from a change in settings.
A 1,021 px wide image slips 3 bytes per row
When handing a numpy array to QImage, always pass bytesPerLine7, the number of bytes in one row. For the constructor that does not take this value, the Qt documentation states that the data and each scanline must be 32-bit aligned. If an 8-bit gray image is not a multiple of 4 pixels wide, numpy’s row spacing and the spacing Qt assumes drift apart, and the image tilts diagonally as each row slips by a few bytes. QImage uses the buffer as it is without copying it, so the buffer also has to stay valid for as long as the QImage lives.
![QImage conversion comparison in which the same 8-bit image skews diagonally and defect shapes change without bytesPerLine but displays correctly with strides[0]](https://noctvision.com/wp-content/uploads/2026/09/noctvision-pyside6-opencv-vision-inspection-gui-03.webp)
Pass a 1,021 px wide image without bytesPerLine, and Qt reads each row as 1,024 bytes, slipping 3 bytes per row. On the left of the figure, the scratch with a 265 px wide bounding box turns into a steep bar 37 px wide, and the spot 11 px across becomes a slanted streak 31 px wide. While 768 rows are read, the 2,304 bytes that run short, about 2.25 rows, come from outside the original image; for the reproduction, the end of the buffer was padded with zeros so they show up as a black band at the bottom. Since the length and position of the defects change together, every measurement taken from display coordinates goes wrong.
The example’s to_qimage() passes img.strides[0], the stride8, which is the row spacing numpy actually uses, as bytesPerLine, and cuts the dependence on the numpy buffer with .copy(). ROI cropping in a camera SDK easily produces images of arbitrary width. I would make one test image with a width deliberately not a multiple of 4 and verify the conversion path with it.
Three lines that count a 60 μm defect
Moved into spec-sheet sentences, the example’s numbers become the three lines below. Since the example takes image files as input, the optical line records only the pixel resolution and the FOV assumption, and the algorithm line is split into four cells.
| Item | Value | Condition · basis | In plain words |
|---|---|---|---|
| ① Minimum defect size | 60 μm long = 3 px | 60 ÷ 20 at 20 μm/px, assumed for the example | A defect stays if it spans three pixels or more |
| ② Optical setup · WD | 8-bit gray 1,024 × 768 px, 20 μm/px (FOV of about 20.48 mm assumed) | An image-file example, so WD is out of scope. A real setup secures WD and the light mounting space first through lens calculation | This example covers only what happens behind the camera |
| ③ Background estimate | Median blur 31 px | Must exceed the widest defect (11 px spot). At 11 px the scratch area drops 1,435 → 1,264 px, at 9 px it breaks into six pieces | A window larger than the defect filters out only the background |
| ③ Threshold · polarity | dark, 25 gray levels | About 8.3 times the synthetic noise σ of 3 levels. One defect at 80 levels, none at 90 | A candidate if 25 levels darker than the background |
| ③ Size filter | 8-connected, bounding-box long side of 3 px or more | 60 ÷ 20. Lowered to 20 μm, the 2 × 2 px speck joins for 3 defects | Dots shorter than three pixels are dropped |
| ③ UI recheck | Debounce 150 ms | Continuous input is inspected once with the last value. Connected through a lambda that discards the argument | Once, 150 ms after the hand stops |
In this example, the detection floor is set jointly by two values: the 3-pixel size filter and the 25-level threshold. On the synthetic image, raising the threshold to 90 levels leaves zero defects, and lowering the minimum defect to 20 μm catches three, including the 2 × 2 px speck that was excluded. To my eye, the hand that turns these two values in the UI is the hand that changes the defect criterion. That is why the parameters have to be saved in the same file as the results.
There are places where this minimal UI does not fit. If the decision logic is a deep learning model, the screen skeleton stays the same but what has to be recorded shifts from thresholds to the model version and input preprocessing conditions (deep learning vs. rule-based article). With several cameras and a short cycle time, running inspection in the GUI thread makes screen updates and inspection block each other, so inspection belongs in a separate thread or process, with the screen left to display results only. On an inspection platform that already tracks parameter history, its own feature serves change tracking better than a separate UI. Whether the background kernel and threshold can be used at the example’s values on real part images with heavy diffuse reflection cannot be guaranteed before a sample test.
Full code and setup
Setup takes three steps. First, create a virtual environment9 with python -m venv .venv and activate it (source .venv/bin/activate on macOS and Linux, .venv\Scripts\activate on Windows). Next, install the packages with pip install PySide6 opencv-python-headless numpy. Install only one OpenCV package, the headless package10 without GUI features; the PyPI page also says to pick only one of the four OpenCV packages. Finally, run python nv_inspect_ui.py (synthetic test image) or python nv_inspect_ui.py part.png.
The code below holds the four parts described earlier, in order: the parameter dataclass, inspect() kept apart from the screen, to_qimage() passing the stride, and the main window with its debounce timer. Formulas and cautions are written into the code comments as well.
# -*- coding: utf-8 -*-
"""
nv_inspect_ui.py — minimal PySide6 + OpenCV vision inspection UI (example)
Environment
- Python 3.10 or later, a virtual environment (venv) is recommended
python -m venv .venv && source .venv/bin/activate (Windows: .venv\\Scripts\\activate)
pip install PySide6 opencv-python-headless numpy
- Install only one OpenCV package: the headless one.
(do not install opencv-python and opencv-python-headless in the same environment)
Usage
python nv_inspect_ui.py # start with the synthetic test image
python nv_inspect_ui.py part.png # start with a saved inspection image (8-bit gray/color)
"""
import csv
import sys
from dataclasses import dataclass
import cv2
import numpy as np
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QImage, QPixmap
from PySide6.QtWidgets import (
QApplication, QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QHBoxLayout,
QLabel, QMainWindow, QPushButton, QSpinBox, QTableWidget, QTableWidgetItem,
QVBoxLayout, QWidget,
)
# ---------------------------------------------------------------------------
# 1) Inspection parameters — one-to-one with the input widgets
# ---------------------------------------------------------------------------
@dataclass
class InspectParams:
um_per_px: float = 20.0 # pixel resolution [um/px] = FOV[mm] x 1000 / horizontal pixels
min_defect_um: float = 60.0 # minimum defect length to detect [um]
bg_kernel_px: int = 31 # background kernel [px] — must be wider than the widest defect, or the defect is absorbed into the background
threshold: int = 25 # threshold on difference from background [gray levels, 0-255]
polarity: str = "dark" # "dark": defects darker than background, "bright": brighter
# ---------------------------------------------------------------------------
# 2) Inspection logic — a pure function separate from the UI (reusable in unit tests and batch runs)
# ---------------------------------------------------------------------------
def inspect(gray: np.ndarray, p: InspectParams):
"""Inspect one gray image and return (defect list, threshold mask, kept mask)."""
# (a) Background estimate: median blur with a kernel larger than the defect -> keeps only illumination shading
k = max(3, p.bg_kernel_px | 1) # kernel must be odd
background = cv2.medianBlur(gray, k)
# (b) Background difference: keep only how far each pixel departs from the background, by polarity
if p.polarity == "dark":
diff = cv2.subtract(background, gray) # saturating subtraction (negative -> 0)
else:
diff = cv2.subtract(gray, background)
# (c) Threshold
_, mask = cv2.threshold(diff, p.threshold, 255, cv2.THRESH_BINARY)
# (d) Connected component labeling (8-connectivity) + size filter
# minimum length [px] = minimum defect [um] / resolution [um/px]
# e.g. 60 um / 20 um/px = 3 px -> shorter components are discarded as noise
min_len_px = p.min_defect_um / p.um_per_px
n, labels, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8)
defects = []
kept = np.zeros_like(mask) # mask of components that pass the size filter
for i in range(1, n): # label 0 is the background
x, y, w, h, area = stats[i]
length_px = max(w, h) # long side of the bounding box as the length
if length_px < min_len_px:
continue # above threshold but smaller than the minimum defect -> removed
kept[labels == i] = 255
defects.append({
"id": len(defects) + 1,
"x": int(x), "y": int(y), "w": int(w), "h": int(h),
"area_px": int(area),
"length_um": round(float(length_px * p.um_per_px), 1), # px -> um
})
return defects, mask, kept
# ---------------------------------------------------------------------------
# 3) numpy -> QImage conversion — always pass the stride (bytesPerLine)
# ---------------------------------------------------------------------------
def to_qimage(img: np.ndarray) -> QImage:
img = np.ascontiguousarray(img)
h, w = img.shape[:2]
if img.ndim == 2:
qimg = QImage(img.data, w, h, img.strides[0], QImage.Format_Grayscale8)
else:
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
qimg = QImage(rgb.data, w, h, rgb.strides[0], QImage.Format_RGB888)
# QImage references the numpy buffer without copying -> return a copy, since the buffer may be freed after the function returns
return qimg.copy()
def make_test_image(w=1024, h=768, seed=7) -> np.ndarray:
"""Synthetic test image: shading + noise + one scratch + one spot + one speck under 3 px."""
rng = np.random.default_rng(seed)
yy, xx = np.mgrid[0:h, 0:w]
img = 150 + 40 * (xx / w) - 25 * ((yy - h / 2) / h) ** 2 # illumination shading, brighter to the right
img = img + rng.normal(0, 3.0, (h, w)) # sensor noise (sigma = 3 gray levels)
img = np.clip(img, 0, 255).astype(np.uint8)
cv2.line(img, (260, 220), (520, 300), 105, 3) # scratch: 3 px wide
cv2.circle(img, (760, 520), 5, 95, -1) # spot: about 11 px in diameter
img[600:602, 420:422] = 110 # speck: 2 x 2 px (under 3 px) -> must be removed by the size filter
return img
# ---------------------------------------------------------------------------
# 4) Main window — re-inspect 150 ms after a parameter change (debounce)
# ---------------------------------------------------------------------------
class MainWindow(QMainWindow):
def __init__(self, gray: np.ndarray):
super().__init__()
self.setWindowTitle("Minimal Vision Inspection — PySide6 + OpenCV")
self.gray = gray
self.defects = []
# image view
self.view = QLabel(alignment=Qt.AlignCenter)
self.view.setMinimumSize(640, 480)
# parameter inputs
self.sp_res = QDoubleSpinBox(decimals=2, minimum=0.5, maximum=500.0, value=20.0, suffix=" um/px")
self.sp_min = QDoubleSpinBox(decimals=1, minimum=1.0, maximum=5000.0, value=60.0, suffix=" um")
self.sp_ker = QSpinBox(minimum=3, maximum=151, singleStep=2, value=31, suffix=" px")
self.sp_thr = QSpinBox(minimum=1, maximum=255, value=25)
self.cb_pol = QComboBox(); self.cb_pol.addItems(["dark", "bright"])
form = QFormLayout()
form.addRow("Resolution", self.sp_res)
form.addRow("Min defect", self.sp_min)
form.addRow("Background kernel", self.sp_ker)
form.addRow("Threshold (gray)", self.sp_thr)
form.addRow("Defect polarity", self.cb_pol)
# verdict, result table, buttons
self.lb_judge = QLabel(alignment=Qt.AlignCenter)
self.lb_judge.setStyleSheet("font-size:22px;font-weight:bold;padding:8px;")
self.lb_info = QLabel() # shows the minimum length [px] and how many components the size filter removed
self.table = QTableWidget(0, 4)
self.table.setHorizontalHeaderLabels(["ID", "Length (um)", "Area (px)", "BBox x,y,w,h"])
self.table.horizontalHeader().setStretchLastSection(True)
self.table.verticalHeader().setVisible(False)
bt_open = QPushButton("Open image…"); bt_open.clicked.connect(self.open_image)
bt_csv = QPushButton("Save CSV log…"); bt_csv.clicked.connect(self.save_csv)
side = QVBoxLayout()
side.addLayout(form); side.addWidget(self.lb_judge); side.addWidget(self.lb_info); side.addWidget(self.table)
side.addWidget(bt_open); side.addWidget(bt_csv)
root = QHBoxLayout(); root.addWidget(self.view, 3); root.addLayout(side, 2)
central = QWidget(); central.setLayout(root); self.setCentralWidget(central)
# debounce timer: however fast the spin boxes change, inspect once with the last value
self.timer = QTimer(self, singleShot=True, interval=150)
self.timer.timeout.connect(self.run)
# note: connecting valueChanged(int) straight to timer.start passes the value as start(msec)
# and changes the debounce interval -> connect through a lambda that drops the argument
for w in (self.sp_res, self.sp_min, self.sp_ker, self.sp_thr):
w.valueChanged.connect(lambda *_: self.timer.start())
self.cb_pol.currentIndexChanged.connect(lambda *_: self.timer.start())
self.run()
def params(self) -> InspectParams:
return InspectParams(self.sp_res.value(), self.sp_min.value(),
self.sp_ker.value(), self.sp_thr.value(), self.cb_pol.currentText())
def run(self):
p = self.params()
self.defects, mask, kept = inspect(self.gray, p)
# overlay: kept defect pixels red, pixels above threshold but removed by size blue, bounding boxes yellow
vis = cv2.cvtColor(self.gray, cv2.COLOR_GRAY2BGR)
vis[(mask > 0) & (kept == 0)] = (255, 120, 0)
vis[kept > 0] = (0, 0, 255)
n_all = cv2.connectedComponents(mask, connectivity=8)[0] - 1
self.lb_info.setText(f"Min length = {p.min_defect_um / p.um_per_px:.1f} px | "
f"components {n_all}, removed by size {n_all - len(self.defects)}")
for d in self.defects:
cv2.rectangle(vis, (d["x"] - 4, d["y"] - 4), (d["x"] + d["w"] + 4, d["y"] + d["h"] + 4), (0, 200, 255), 2)
cv2.putText(vis, str(d["id"]), (d["x"], d["y"] - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 200, 255), 2)
pix = QPixmap.fromImage(to_qimage(vis))
self.view.setPixmap(pix.scaled(self.view.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation))
# verdict: NG if at least one defect is kept
ng = len(self.defects) > 0
self.lb_judge.setText(f"NG — {len(self.defects)} defect(s)" if ng else "OK")
self.lb_judge.setStyleSheet("font-size:22px;font-weight:bold;padding:8px;color:#fff;"
+ ("background:#c0392b;" if ng else "background:#2e7d32;"))
self.table.setRowCount(len(self.defects))
for r, d in enumerate(self.defects):
for c, v in enumerate([d["id"], d["length_um"], d["area_px"], f'{d["x"]},{d["y"]},{d["w"]},{d["h"]}']):
self.table.setItem(r, c, QTableWidgetItem(str(v)))
def resizeEvent(self, e):
super().resizeEvent(e)
self.timer.start() # refit the display scale when the window is resized
def open_image(self):
path, _ = QFileDialog.getOpenFileName(self, "Open image", "", "Images (*.png *.bmp *.tif *.jpg)")
if path:
img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
if img is not None:
self.gray = img
self.run()
def save_csv(self):
path, _ = QFileDialog.getSaveFileName(self, "Save CSV log", "defects.csv", "CSV (*.csv)")
if not path:
return
p = self.params()
with open(path, "w", newline="", encoding="utf-8") as f:
wr = csv.writer(f)
# keeping the parameters with the results is what makes a verdict reproducible later
wr.writerow(["um_per_px", p.um_per_px, "min_defect_um", p.min_defect_um,
"bg_kernel_px", p.bg_kernel_px, "threshold", p.threshold, "polarity", p.polarity])
wr.writerow(["id", "length_um", "area_px", "x", "y", "w", "h"])
for d in self.defects:
wr.writerow([d["id"], d["length_um"], d["area_px"], d["x"], d["y"], d["w"], d["h"]])
if __name__ == "__main__":
app = QApplication(sys.argv)
src = cv2.imread(sys.argv[1], cv2.IMREAD_GRAYSCALE) if len(sys.argv) > 1 else None
win = MainWindow(src if src is not None else make_test_image())
win.resize(1280, 760)
win.show()
sys.exit(app.exec())
Run it, and NG with 2 defects appears on the synthetic image; raise the threshold to 90 levels and the defects disappear; lower the minimum defect to 20 μm and three are caught, the speck included. Trying the three moves yourself teaches by hand how two parameters move the defect criterion. I also suggest opening the saved CSV and checking that the parameters on its first line match the values on screen.
Pulling the inspection logic out into a function and putting a screen on top is a good first exercise for anyone starting vision work in Python. It builds the habit of checking, without a screen, that the same input gives the same result. From the side that buys the equipment, whether the inspector saves its parameters with every verdict decides the cost of tracing a defect’s cause later. This article explains technology and is not investment advice. See our Disclaimer.
Field Note
Before handing over an inspection tool with on-screen thresholds for use beside the line, I would first confirm that the parameters land on the first line of the CSV. When the same defect comes back a few days later without the threshold and kernel used at the time on record, there is no telling whether overdetection came from a change in lighting or a change in threshold. I make one test image at a size that is not a multiple of 4, such as 1,021 px wide, to line up the display coordinates, and measure again whether the background kernel is larger than the widest defect. The handover document also states that a screen like this cannot make up for contrast the lighting and lens did not create.
Field Checkpoints
Moved into the check items of the handover document, the order from the field note reads as below.
- Is the WD of the camera and lens that produce the images for this UI secured, including the light mounting space? (parameter tuning only means something on good images)
- Does the resolution input (μm/px) match the real FOV ÷ horizontal pixels? (update this value first whenever the lens or distance changes)
- Is the background kernel larger than the widest defect? (15 px or more for an 11 px spot)
- Have display coordinates and pixel values been verified with an image whose width is not a multiple of 4? (a 1,021 px test image)
- Does the first line of the CSV record the parameters along with the results? (resolution, minimum defect, kernel, threshold, polarity)
- Was a virtual environment created, with only the headless OpenCV package installed? (do not mix two OpenCV packages in one environment)
Frequently Asked Questions
Is there a reason to use PySide6 over PyQt?
The code in this example is nearly identical under both bindings. PySide6 was chosen because, as the official Python binding provided by the Qt project itself, its documentation and versions are easy to match against Qt. License terms for distribution have to be checked separately against the policy of whoever uses it.
How do I connect real camera images?
inspect() takes only an 8-bit gray numpy array, so convert the frame from the camera SDK to the same format and pass it in. Real-time acquisition, though, needs a structure that receives frames in a separate thread and hands only the latest frame to the screen. Make screen updates wait on acquisition, and frames pile up or drop.
What happens with a color image?
The example reads files with cv2.IMREAD_GRAYSCALE, converting them to 8-bit gray. Only the display overlay is built in BGR color, and to_qimage() converts it to RGB before handing it over. If defects can be told apart only by color, a separate step that picks a channel before the gray conversion is needed.
Glossary
- [1] Pure function pure function
A function that always returns the same result for the same input and changes nothing outside itself. It can be tested without a screen, which suits inspection logic. ↩ - [2] Median blur median blur
A filter that lines up the pixel values in a window by size and replaces the center with the middle value. It erases defects smaller than the window and keeps only broad brightness trends, so it serves as a background estimate. ↩ - [3] Connected component labeling connected component labeling
A step that numbers each clump of touching pixels in a binary image. With 8-connectivity, pixels touching diagonally count as the same clump. ↩ - [4] Pixel resolution μm/px
The length on the inspected surface that one pixel covers. At 20 μm/px, each pixel is responsible for 20 μm of the part. ↩ - [5] Debounce debounce
A way of gathering inputs that arrive in quick succession and handling only the last one. It is like an elevator that comes once no matter how many times the button is pressed. ↩ - [6] Signal and slot signal and slot
The Qt mechanism that connects a widget event (signal) to a handler (slot). When a signal carries a value, that value is passed as the slot’s argument. ↩ - [7] bytesPerLine scanline bytes
The number of bytes one row of the image occupies in memory. It may differ from the pixel count, so it has to be given to QImage separately. ↩ - [8] Stride stride
The number of bytes skipped to move to the next row of an array. A numpy array’sstrides[0]is its row stride. ↩ - [9] Virtual environment venv
A self-contained folder environment that installs Python packages separately for each project. It keeps one project’s OpenCV version from mixing with another’s. ↩ - [10] Headless package opencv-python-headless
An OpenCV distribution built without the GUI features that open windows. PySide6 handles the screen, so it does not clash with OpenCV’s own window features. ↩
References
- Qt 6 documentation, QImage: 32-bit alignment for the constructor without bytesPerLine, buffer lifetime
- Qt for Python (PySide6) | Official Python Bindings: the official Qt Python bindings
- PyPI, opencv-python-headless: install only one of the four packages (“SELECT ONLY ONE OF THEM”), no GUI functionality
- OpenCV documentation, Structural Analysis and Shape Descriptors: connectedComponentsWithStats
- Check environment: the text’s numbers for
inspect(), the debounce wiring and the QImage conversion were rerun on Python 3.13, PySide6 6.12, OpenCV 5.0 (headless) and numpy 2.5
Related reading — Deep Learning vs. Rule-Based Algorithms, When to Use Which · In Machine-Vision Inspection, Physics Comes Before Software · On Reflective Surfaces, a Bilateral Filter Keeps the Crack a Gaussian Blurs Away


