Core.perspectiveTransform has stopped your build with the assert scn + 1 == m.cols, raised from matmul.cpp in the core module. You matched features, packed corners into Mats, ran findHomography, called the transform — and it fires even though both point Mats are already CV_32FC2. There are exactly two causes: the point Mat's channel count does not satisfy scn + 1 == m.cols against a 3x3 homography, or findHomography returned an empty Mat because RANSAC failed, and an empty Mat has 0 columns. You can separate the two in under two minutes: print the Mat shapes, check homography.empty(), then either repack the points or fix the matching. The same checks cover cv2.perspectiveTransform in Python and the C++ call underneath both.
The rule the assert enforces
perspectiveTransform moves a set of points through a projective matrix, so the point layout and the matrix shape have to agree exactly. scn is the channel count of your input point Mat. m.cols is the column count of the matrix you pass as the last argument. The call proceeds only when scn + 1 == m.cols. The +1 is not decoration: each 2D point is extended to (x, y, 1) before the multiply, and that homogeneous coordinate is what the third matrix row divides by afterwards.
| Point Mat | scn | A 3x3 H passes the check? |
|---|---|---|
| 4x1, CV_32FC2 | 2 | yes — 2 + 1 = 3 |
| 4x2, CV_32FC1 | 1 | no — 1 + 1 = 2, and 2 ≠ 3 |
| Nx1, CV_64FC2 | 2 | yes — 64-bit float depth is allowed too |
| Nx1, CV_32FC3 | 3 | no — 3 + 1 = 4, needs a 4x4 matrix |
| empty Mat from a failed findHomography | any | no — H.cols() is 0, and scn + 1 ≠ 0 |
Depth is part of the same contract: the point Mat must be CV_32F or CV_64F, so an integer point Mat fails a depth check before the columns are ever compared. For the everyday 2D case this collapses to one sentence: points in as an Nx1 CV_32FC2 Mat, matrix is the 3x3 homography.
Cause 1: the point Mat has one channel
Coordinates feel like rows of (x, y), so a 4x2 CV_32FC1 Mat looks like the natural packing — and it fails, because one channel gives scn = 1, so the assert wants m.cols = 1 + 1 = 2 while your homography has 3 columns. The values inside can be perfect and the assert still fires.
The test is one line. Print pointMat.channels() and H.cols() immediately before the call; healthy values are 2 and 3. If you get 1 and 3, repack the points as an Nx1 two-channel Mat with x and y interleaved:
// Illustrative variable names; CvType and Mat are the OpenCV API.
Mat corners = new Mat(4, 1, CvType.CV_32FC2); // N x 1, two channels, 32-bit float
corners.put(0, 0, new float[]{ // x, y interleaved, in match order
120.5f, 88.0f, 342.0f, 90.5f,
350.5f, 260.0f, 118.0f, 262.5f});
Pack the destination Mat the same way, with each coordinate in exactly the same match order as its source — a swapped pair passes every shape check and bends the result instead. In Python, NumPy produces the required shape directly:
import numpy as np
pts = np.array([[120.5, 88.0], [342.0, 90.5],
[350.5, 260.0], [118.0, 262.5]],
dtype=np.float32).reshape(-1, 1, 2) # shape (N, 1, 2): Nx1, 2 channels
cv2.perspectiveTransform(pts, H) passes the same assert with these values, because pts carries two channels and H is 3x3.
Cause 2: findHomography returned an empty Mat
This is the trap when the point Mats are already CV_32FC2: scn and the matrix look right, and the assert fires anyway. Called with RANSAC, findHomography returns an empty Mat when it cannot find a valid homography — fewer than four matches survive the reprojection threshold as inliers. An empty Mat has 0 rows and 0 columns, so the check becomes 2 + 1 == 0, that is 3 == 0, and it fails with the same scn + 1 == m.cols message. Your point Mat is never even inspected.
Worked through with the packing above:
- corners: 4x1 CV_32FC2, so scn = 2 and the assert demands m.cols = 2 + 1 = 3
- healthy result: 3x3, so m.cols = 3 and 3 == 3 — the call proceeds
- failed RANSAC: empty Mat, so m.cols = 0 and 3 ≠ 0 — the assert fires
Same point Mats, same message, opposite causes. The library caps RANSAC internally at about 2000 iterations, so patience does not rescue a hard set: if no four-point sample builds a consensus under the threshold, the result stays empty.
Test for this cause before touching anything else:
// srcPts and dstPts are Mat(4, 1, CvType.CV_32FC2) built as above; illustrative names.
Mat H = Calib3d.findHomography(srcPts, dstPts, Calib3d.RANSAC, 3.0);
if (H.empty()) { // RANSAC failed: fewer than 4 inliers
throw new IllegalStateException("findHomography returned an empty Mat");
}
Mat mapped = new Mat(); // output: same 4x1 CV_32FC2 layout
Core.perspectiveTransform(srcPts, mapped, H); // 2 + 1 == 3: passes
In Python the same condition is H is None after cv2.findHomography. With that guard in place the assert cannot fire from an empty result, and any remaining failure points back to cause 1.
Make RANSAC find the homography again
Fix the matcher in this order. Each step either brings the result back or shows that the image set-up, not the parameters, is the limit.
- Relax the reprojection threshold. The last findHomography argument is the largest reprojection error, in pixels, for a match to count as an inlier. A 1 px value leaves almost no slack: corner localization alone drifts a few tenths of a pixel, and residual lens distortion adds more. Set 3 px, the library default, and raise toward 5 px on 12 MP-class frames — an estimate; confirm it against the inlier mask.
- Add a ratio test before RANSAC. Keep a descriptor match only when its distance is under 0.7 to 0.8 times the second-best match for that descriptor. That is the usual range for removing ambiguous matches, and it removes the outliers RANSAC would otherwise choke on.
- Count and spread the matches. Four point pairs is the mathematical minimum; aim for 30 or more, spread over the part. For a set built from four known points, keep them near the frame corners, the way the ground plane pose from four known points walkthrough lays them out.
- Verify what you packed. The Mats handed to findHomography must hold keypoint coordinates (pt.x, pt.y) — not responses, not match indices — as CV_32FC2, in the same order on both sides.
- Check the image pair itself. Blur, a large scale change or a low-texture part caps the match count no matter the parameters. On a small template, grow the region of interest or improve contrast and light before tuning anything.
When RANSAC succeeds it returns a 3x3 matrix plus a mask of inliers; count the mask and log it. A stable station keeps dozens of inliers frame to frame, and a falling count is your early warning before the empty result returns.
What the 3x3 does once the check passes
The shape rule also tells you why an affine shortcut will not do. Take a homography that shifts x by 10 px, shifts y by 5 px and adds a slight depth term:
H = [[1, 0, 10],
[0, 1, 5],
[0.001, 0, 1]]
Mapping the point (100, 200) px runs three multiplies and one divide:
- x' = 1×100 + 0×200 + 10 = 110
- y' = 0×100 + 1×200 + 5 = 205
- w' = 0.001×100 + 0×200 + 1 = 1.1
- result: (110 / 1.1, 205 / 1.1) = (100.0, 186.4) px
A 2x3 affine matrix has nowhere to put that third row, so it cannot divide by w — which is why perspectiveTransform insists on scn + 1 == m.cols instead of also accepting scn == m.cols.
perspectiveTransform, transform or warpPerspective
The three similarly named functions check different shapes, and picking the wrong one mimics this assert:
| Function | First argument | Matrix it accepts | Perspective divide |
|---|---|---|---|
| perspectiveTransform | point Mat, Nx1 CV_32FC2 or CV_64FC2 | scn + 1 == m.cols — 3x3 for 2D | yes |
| transform | point Mat, CV_32F or CV_64F | scn == m.cols or scn + 1 == m.cols — 2x2 or 2x3 for 2D | no |
| warpPerspective | full image, CV_8U, CV_16U or CV_32F | 3x3 | yes, per pixel |
Use transform when the relationship is genuinely affine and your matrix is 2x3; use perspectiveTransform for point sets through a real homography; use warpPerspective when the whole image moves. The coordinate math for that mapping — points in, mapped points out, one divide each — is walked through end to end in Homography pixel coordinates: map points into a warped image. When it is the image rather than the points that throws, the array and canvas sizing behind warpPerspective failures is covered in Homography warp array type error: check data and canvas.
Guard code and a one-point self-test
Put the whole shape contract into one helper and call it before every transform, so the failure mode becomes a log line instead of a crash:
static boolean usableHomography(Mat H, Mat points) {
// 2D pipeline: 3x3 H, Nx1 CV_32FC2 (or CV_64FC2) points.
return H != null && !H.empty()
&& H.rows() == 3 && H.cols() == 3
&& points.channels() == 2
&& (points.type() == CvType.CV_32FC2 || points.type() == CvType.CV_64FC2);
}
Prove the pipeline with the identity homography. Set H to the 3x3 identity and transform your production point Mat: with H = [[1, 0, 0], [0, 1, 0], [0, 0, 1]], the point (100.0, 200.0) maps to x' = 100.0, y' = 200.0, w' = 1, so the output is (100.0 / 1, 200.0 / 1) = (100.0, 200.0) px — identical to the input. If that test passes, the points are the right shape and any later assert can only come from the homography side. If it asserts, you are back at cause 1.
Prevention, in one list:
- Log H.rows(), H.cols() and points.channels() on every failure path, not just the first one.
- Never chain findHomography and perspectiveTransform in one expression; keep the empty check between them.
- Pack coordinates in a single helper so source and destination order cannot drift apart.
- Treat an empty homography as a data problem — matches, blur, geometry — before touching matrix code.
The same discipline catches the neighbouring Mat bugs too: the empty-frame assert behind cv2 imwrite assertion failed: fix the frame first fails for the same reason, a Mat used before anyone checked it holds data. Type mismatches rather than shapes are the theme in Colour frame comes out doubled: match Mat type to pixel format.
The same mapping on the line
None of this stays on the bench. In a deployed inspection, perspectiveTransform usually sits inside the per-part cycle, mapping detected features into belt or robot coordinates. A glossy part, a swinging shadow or a bumped focus can push inliers under four mid-shift, so the empty check belongs in the production path: reject the part, log the inlier count, keep the line running.
The code runs on a vision controller next to the cameras. A multi-camera GigE station with light control and this mapping at cycle rate is a normal load for the VC3000 series. The MV-VC3301H-128G66 runs the pipeline on an Intel Core i3-8100 with 8 GB of memory and a 128 GB SSD, provides 5+ LAN ports and four built-in light channels, and includes the VM6200Pro license. Where the station grows to PoE cameras and heavier models, the MV-VC5525-256G70-NN adds PoE camera ports, 32 GB of memory and 8 built-in light channels.
- LAN ports
- 5+ LAN ports
- USB 3 ports
- 3-4 USB 3 ports
- LAN ports
- 5+ LAN ports
- USB 3 ports
- 3-4 USB 3 ports
When the inspection should live on the camera itself with no PC-side pipeline, compare the smart cameras instead. For the controller route, start from the vision controllers page to sort port counts and memory, or contact us with your camera, light and port list and we will help you size the box.
Questions
How many matched point pairs does findHomography need before it returns a homography?
Four is the mathematical minimum, and the function needs at least that to solve for the eight degrees of freedom of a homography. In practice feed 30 to 100 well-spread matches: RANSAC estimates the eight parameters from random four-point samples, so a thin match set samples few combinations and one noisy pair drags the estimate off. With fewer than four inliers, RANSAC gives up and returns empty.
What does the RANSAC reprojection threshold in findHomography actually control?
It is the maximum reprojection error, in pixels, for a match to count as an inlier: the distance between a destination point and where the current candidate homography maps its source point. The library default is 3 px. At 1 px, corner-localization noise of a few tenths of a pixel plus any lens distortion already consumes the whole budget, so almost nothing survives as an inlier and the result comes back empty.
Why does transform accept my 2x3 matrix while perspectiveTransform refuses it?
The two functions check different shapes. transform accepts scn == m.cols or scn + 1 == m.cols — a 2x2 linear matrix or a 2x3 affine one for 2-channel points — and applies no perspective division. perspectiveTransform insists on scn + 1 == m.cols, so 2-channel points demand the full 3x3, because it divides by the homogeneous coordinate at the end. Feeding a 2x3 affine matrix to perspectiveTransform fails the same assert.
Can I pass integer pixel coordinates straight into perspectiveTransform?
No. perspectiveTransform accepts CV_32F or CV_64F depth only, so a CV_32SC2 point Mat fails a depth check before the column comparison even runs. Convert first: Mat.convertTo(dst, CvType.CV_32F) keeps the two channels and produces CV_32FC2 in Java, and .astype(np.float32) does the same in Python. Detector keypoint coordinates are already floats, which is why this bites only when you pack measured pixel positions by hand.

