Your Python script finds all 42 corners of a 4 x 15-square chessboard. Your C++ program, built against the same OpenCV 4.6.0, running the same three flags on the same image, returns nothing, or a partial set that looks plausible and is not. In practice the gap comes from three places: the patternSize pair passed in a different order, the CALIB_CB_FAST_CHECK pre-scan discarding a marginal board, and two differently compiled builds of the same version feeding the detector slightly different pixels. All three are fixable in an afternoon without touching your calibration math. This guide gives the test that isolates each cause, the numbers for a board that keeps the problem from coming back, and the checks to run before you trust either environment.
The symptom, and what it rules out
Both programs load the same image, convert it with COLOR_BGR2GRAY, and call findChessboardCorners with CALIB_CB_ADAPTIVE_THRESH + CALIB_CB_FAST_CHECK + CALIB_CB_NORMALIZE_IMAGE. Python returns the full point set. C++ returns false, or true with fewer points than the board holds.
The matching version string and matching flags rule out the obvious suspects. What remains is what the two detectors actually receive: the patternSize argument, the effect of the FAST_CHECK flag, and the exact pixel values each compiled build feeds in. Chessboard detection runs on strong, even gradients between black and white squares, a method with decades of published work behind it (chessboard detection). Small input differences move a real board across the accept/reject line.
Cause 1: the patternSize pair is transposed
The patternSize argument counts inner corners, points per row first: cv::Size(points_per_row, points_per_column), which is cv::Size(columns, rows). The Python binding takes the same two numbers in the same order, so Python (3, 14) and C++ cv::Size(3, 14) are one request: 3 inner corners along each row and 14 along each column. C++ cv::Size(14, 3) describes a different board, turned a quarter turn. This argument has caught enough users to fill an OpenCV issue tracker of its own.
| Board (squares) | Inner corners | Python | C++ |
|---|---|---|---|
| 4 x 15 | 3 per row x 14 per column | (3, 14) | cv::Size(3, 14) |
| 15 x 4 | 14 per row x 3 per column | (14, 3) | cv::Size(14, 3) |
| 9 x 6 | 8 per row x 5 per column | (8, 5) | cv::Size(8, 5) |
On a clean image the transposed request fails outright. On a marginal image the detector can lock onto part of the grid and return points whose order differs from Python's, and order is everything once you pair corners between views.
The test takes one minute. Draw the overlay with drawChessboardCorners in Python, count the marker rows and columns on screen, and print the tuple. Mirror those two numbers in C++:
# per_row, per_col = inner corners along a row, along a column
# gray = 8-bit grayscale image, flags = your flag combination
per_row, per_col = 3, 14
ret, corners = cv2.findChessboardCorners(gray, (per_row, per_col), flags)
assert len(corners) == per_row * per_col # full board: 42 points
// same pair, same order: points per row, then points per column
const int per_row = 3, per_col = 14;
cv::Size pattern(per_row, per_col);
std::vector<cv::Point2f> corners;
bool found = cv::findChessboardCorners(gray, pattern, corners, flags);
// full board: found == true and corners.size() == 42
Cause 2: CALIB_CB_FAST_CHECK rejects the board
FAST_CHECK exists for speed: it runs a quick pre-scan and, when that pre-scan sees no board, the function returns false without attempting the full search. That trade suits a live preview where you poll for board presence at frame rate and most frames are empty. It does not suit calibration capture, because a board the full search would accept — a thin grid, slight defocus, uneven lighting — gets discarded by the pre-scan first. Once the pattern size matches, this flag is the most common reason one environment succeeds and the other refuses.
The test: delete CALIB_CB_FAST_CHECK from both programs, keep CALIB_CB_ADAPTIVE_THRESH + CALIB_CB_NORMALIZE_IMAGE, and re-run the same images. The call takes longer per frame; that is the full search doing the work the pre-scan skipped.
The decision: drop FAST_CHECK on every frame you calibrate from. Keep it, if at all, on the preview path where its speed was the point.
Cause 3: two builds behind one version number
A version string of 4.6.0 pins an API, not the compiled code. The pip wheel ships its own optimization paths and its own image codecs. Your C++ toolchain links a library built with different SIMD flags and possibly a different JPEG decoder. Two decoders can differ by a level or two on the same file, and a corner sitting near the adaptive threshold flips on exactly that difference.
The test: have both programs print the average gray value of the decoded image to three decimals. Different averages mean different pixels reached the detector, and no argument tuning will reconcile them. The fix is to take decoding out of the comparison: convert your captures once to 8-bit PNG, a lossless format, and feed that same file to both programs. If the averages match and the corners still differ, the remaining gap is the compiled detector itself, and the answer is to build both sides from the same source revision.
The decision: calibrate from lossless frames. A JPEG decoder is not part of a measurement chain.
Worked example: one board, both bindings, all the numbers
Take the failing board: 4 squares across and 15 down, so 3 inner corners per row and 14 per column, 3 x 14 = 42 points. The image is 1080 x 720 with the long dimension of the board spanning the full 720 px:
- Square pitch: 720 px / 15 squares = 48 px
- Board width: 4 squares x 48 px = 192 px
- Subpixel window: the (3, 3) argument of cornerSubPix is a half-size, so the search spans 7 x 7 px, which is 7 / 48 or about 15 percent of the pitch
Fifteen percent of pitch is comfortable for sharply focused squares. When the edges are soft, widen the window to (5, 5), an 11 x 11 px search, and keep the same 30-iteration, 0.1 stop criteria. Then hold per_row = 3 and per_col = 14 on both sides, feed both programs the same lossless file, and leave FAST_CHECK off: both return the full point set. Confirm the start corner once in the drawn overlay of each environment before you pair anything.
Give the detector more pixels per square
Every fix above is software. The durable fix is pixels: plan for at least 20 px per square, because the subpixel stage fits a small window around each corner and needs several pixels of clean gradient on both sides of the crossing. You buy that room with focal length and working distance, from two lines of arithmetic:
Work a 200 mm board, the 15-square dimension, on a 1/1.8-inch sensor (about 7.2 x 5.4 mm as a format convention), filling 80 percent of sensor height, viewed from 500 mm:
- m = 4.32 mm / 200 mm = 0.0216
- f = 500 mm x 0.0216 / 1.0216 = 10.6 mm, so choose a 12 mm lens
- Check at 12 mm: m = 12 / (500 - 12) = 0.0246; field height = 5.4 mm / 0.0246 = 220 mm, so the 200 mm board fits with about 20 mm to spare
- One square on sensor: (200 mm / 15) x 0.0246 = 0.328 mm; at 2.0 µm pixels that is 328 / 2.0 = 164 px per square, eight times the floor
An 8.5 mm lens at the same station gives m = 8.5 / (500 - 8.5) = 0.0173, 115 px per square and a 312 mm field height — five times the floor with far more room to move. Take the 12 mm focal length when the stand holds distance precisely; take the shorter focal length when the board position wanders. This is the same working distance and field of view formula used to lay out inspection cells, and the lens focal length calculator runs it; its button opens the lens finder filtered to the focal length it computes.
For capture, a 12 MP USB3 camera such as the LS-CMR-0166 outputs 4000 x 3000 pixels at 30 fps:
- Sensor format
- 1/1.7"
- Interface
- USB3
- Resolution
- 12 MP
- Shutter
- Rolling
Two C-mount lenses bracket that station, the 8.5 mm LS-FFL-0602 and the 16 mm LS-FFL-0567:
| Specification | LS-FFL-0602 | LS-FFL-0567 |
|---|---|---|
| Resolution | 10 Megapixel | 20 Megapixel |
| Format | 1" | 1.1" |
| Focal length | 8.5 mm | 16 mm |
| Aperture | F2.4 Manual iris | F2.8-22 |
| Filter thread | M77 x P0.75 | M35.5 x P0.5 |
| Price | $429.00 | $679.00 |
All 2 share: M.O.D. 0.1 m; Mount C-mount. Values as printed in each product's data sheet; open a part for its drawing and full specifications.
And hold the station still, because vibration is blur in every corner: a slide-table stand such as the LS-MNT-0040, with 700 mm of column height and a 400 x 450 mm base, keeps working distance where you set it:
- Type
- Camera stands
Once the numbers are fixed, browse area scan cameras, machine vision lenses and camera stands for the rest of the station.
Isolate the cause in fifteen minutes
- Print the pattern pair in both programs and compare position by position: Python (per_row, per_col) against C++ cv::Size(per_row, per_col).
- Draw the overlay in both environments and confirm the same start corner and the same row direction.
- Remove CALIB_CB_FAST_CHECK in both and re-run the failing captures.
- Feed both programs one lossless PNG and compare the printed average gray value to three decimals.
- Only after those four pass, touch the hardware: even up the lighting and raise the pixels per square.
Checks before you trust the corners
- The point count equals per_row x per_col in both environments, on every frame you keep.
- A count check alone cannot catch transposition on this board, because 3 x 14 and 14 x 3 both total 42. The drawn overlay is the check that catches it.
- Judge the calibration itself by reprojection error once detection agrees, against the thresholds in camera calibration residual values: average and max targets.
- Lock the lens focus before you calibrate; refocusing afterward invalidates the mapping — see calibrate a camera with autofocus: lock the focus first.
When both languages agree the board is bad
With the pair matched, FAST_CHECK off, and both builds reading the same lossless pixels, identical failures in both languages are the detector telling the truth. Three target faults dominate. Glossy lamination throws highlights across the squares; print matte. Squares that touch with visible seams and gaps break the quad grouping; fix them with checkerboard corners not detected: fix gaps in your target. And a board that is simply small at the working distance needs more squares or a longer lens — size it with checkerboard calibration target size: how many squares and how big. The same logic scales for distant targets, as in checkerboard calibration fails at distance: depth camera target size.
When the software matches and you want hardware that matches it, start in the configurator with your board size and working distance, or reach us through the contact page and we will run the numbers with you.
Questions
Is the Python patternSize tuple in a different order than the C++ Size?
No — the tuple is (inner corners per row, inner corners per column), the same order as cv::Size(points_per_row, points_per_column) in C++. A classic 9 x 6-square board is 8 x 5 inner corners: (8, 5) in Python, cv::Size(8, 5) in C++, 40 points. Count the squares and subtract one per axis. Keep that pair in one shared config both programs read, so the two calls can never drift apart.
What does CALIB_CB_FAST_CHECK actually change?
It runs a quick pre-scan and returns false, without the full search, when the pre-scan finds no board. The speedup shows on video streams where most frames are empty, which is the opposite of a calibration capture. There, a marginal board the full search would accept gets rejected first. Drop CALIB_CB_FAST_CHECK on every frame you calibrate from, keep CALIB_CB_ADAPTIVE_THRESH and CALIB_CB_NORMALIZE_IMAGE, and accept the slower call.
Why can two installs of OpenCV 4.6.0 disagree on the same image?
The version string pins the API, not the compiled code. A pip wheel ships its own optimization paths and its own image codecs, and a locally built C++ library can differ in both. Two decoders can differ by a level or two on one JPEG and flip corners near the threshold. Print the build information string each binding reports, and calibrate from lossless PNG frames so decoding stops being a variable between the two programs.
How many pixels should each chessboard square cover?
Plan for at least 20 px per square, and prefer double that. The subpixel stage fits a window of a few pixels around each corner and needs clean gradient on both sides of the crossing to land repeatably. At 20 px pitch a 7 x 7 px window covers about a third of a square, which is the practical floor. Angled boards and soft focus eat that margin fast, which is why the worked 48 px pitch is comfortable.
Does a square board pattern cause cross-language mismatches?
It can. A square grid matches after a 90-degree turn, so two builds can return the same points in transposed order and both be right. Keep the per-axis counts different, such as 8 x 5, and a quarter-turn cannot match at all. Only a consistent 180-degree reversal remains, and the drawn overlay exposes it at a glance because the first marker lands somewhere unexpected.

