A seamless-texture test that can actually fail
Most seam tests pass on textures with visible seams, because they measure the wrong thing.
The test everyone writes first
Tile the texture two by two, then compare the pixels down the join with the pixels either side of it. If the difference is small, call it seamless.
This passes almost anything. A texture with plenty of local contrast - gravel, bark, rust - has neighbouring pixels that differ a lot everywhere, so a seam that differs by the same amount hides inside the noise. The threshold that catches the seam also condemns the texture's own detail, and the threshold that spares the detail lets the seam through. There is no setting that works, which is the tell that the measurement is wrong rather than badly tuned.
Measure the seam against its own neighbourhood
What a viewer notices is not that the join differs. It is that the join differs more than the surrounding texture differs from itself. So that is what to compute: the mean absolute difference across the join, divided by the mean absolute difference across a column of the same texture a few pixels to the left, where there is no join at all.
def seam_ratio(img, axis=0):
a = np.roll(img, 1, axis=axis)
join = np.abs(img.take(0, axis) - a.take(0, axis)).mean()
# The same measurement somewhere the texture is continuous.
inland = np.abs(np.diff(img, axis=axis)).mean()
return join / max(inland, 1e-6)
A value near 1.0 means the join is as ordinary as the rest of the image. Anything above about 1.4 is a seam you can see. The number is scale-free, so one threshold works for gravel and for a smooth gradient, which the absolute version never managed.
The bug it caught
A noise field built from a lattice was seamless in x and not in y. The absolute test passed it: the texture was busy enough to swallow the difference. The ratio test put y at 2.3 while x sat at 1.02, which is exactly the shape of "one axis wraps and the other does not".
The cause was an off-by-one in the wrap of the gradient lattice - the last row of gradients was a fresh row rather than a copy of the first. Ten characters. Invisible in a thumbnail, obvious on a wall in a shipped game, and undetectable by the test that had been guarding it.
The rule underneath
A test that cannot fail is not a test. Before trusting one, find the bug it is supposed to catch, introduce that bug deliberately, and check that the test goes red. If it does not, the test is measuring something adjacent to the thing you care about, and it will keep passing right up until a customer finds the seam for you.