Skip to content

Commit a61d379

Browse files
committed
Use band means instead of sums to avoid potential bias in NDVI
1 parent b2824b2 commit a61d379

2 files changed

Lines changed: 214 additions & 4 deletions

File tree

‎spectral/algorithms/algorithms.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,10 +1164,10 @@ def ndvi(data, red, nir):
11641164

11651165
r = data[:, :, red].astype(float)
11661166
if len(r.shape) == 3 and r.shape[2] > 1:
1167-
r = sum(r, 2) / r.shape[2]
1167+
r = np.mean(r, axis=2)
11681168
n = data[:, :, nir].astype(float)
11691169
if len(n.shape) == 3 and n.shape[2] > 1:
1170-
n = sum(n, 2) / n.shape[2]
1170+
n = np.mean(n, axis=2)
11711171

11721172
return (n - r) / (n + r)
11731173

@@ -1257,7 +1257,7 @@ def transform_image(matrix, image):
12571257
ximage[i, j] = np.dot(matrix, image[i, j].astype(float))
12581258
return ximage
12591259
else:
1260-
raise 'Unrecognized image type passed to transform_image.'
1260+
raise TypeError('Unrecognized image type passed to transform_image.')
12611261

12621262

12631263
def orthogonalize(vecs, start=0):

‎spectral/tests/test_algorithms.py‎

Lines changed: 211 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,22 @@
55
# pytest spectral/tests/test_algorithms.py
66
'''
77

8+
from types import SimpleNamespace
9+
810
import numpy as np
911
from numpy.testing import assert_allclose
1012
import pytest
1113

1214
import spectral as spy
1315
from spectral.algorithms.algorithms import (mean_cov, calc_stats, iterator,
1416
iterator_ij, ImageIterator,
15-
ImageMaskIterator)
17+
ImageMaskIterator, GaussianStats,
18+
ndvi, bdist, bdist_terms,
19+
cov_avg, covariance, log_det,
20+
transform_image, unmix,
21+
spectral_angles, msam)
1622
from spectral.algorithms.transforms import LinearTransform
23+
from spectral.io.spyfile import TransformedImage
1724
from spectral.utilities.errors import NaNValueError
1825

1926

@@ -267,3 +274,206 @@ def test_num_from_kwargs_rejects_both(self, mnfr):
267274
def test_num_from_kwargs_rejects_unknown_kwarg(self, mnfr):
268275
with pytest.raises(Exception):
269276
mnfr._num_from_kwargs(bogus=1)
277+
278+
279+
class TestNdvi:
280+
281+
@pytest.fixture
282+
def wide_data(self):
283+
'''A synthetic image with more bands than the shared `data`
284+
fixture, wide enough for multi-band red/nir ranges.'''
285+
return np.random.RandomState(0).rand(6, 5, 10)
286+
287+
def test_single_band(self, wide_data):
288+
red = 2
289+
nir = 6
290+
result = ndvi(wide_data, red, nir)
291+
r = wide_data[:, :, red].astype(float)
292+
n = wide_data[:, :, nir].astype(float)
293+
assert_allclose(result, (n - r) / (n + r))
294+
295+
def test_multi_band_range_is_unbiased_mean(self, wide_data):
296+
'''Regression test: `ndvi` used to sum (rather than average) a
297+
multi-band red/nir range using Python's builtin `sum()`, which
298+
doesn't operate along the band axis at all -- it silently
299+
produced a wrong-shaped, wrong-valued result. Using unevenly
300+
sized red/nir ranges here would also catch a reintroduced version
301+
of that bug that summed instead of averaged (which would bias the
302+
result toward whichever range has more bands).'''
303+
red = slice(2, 4) # 2 bands
304+
nir = slice(5, 8) # 3 bands
305+
result = ndvi(wide_data, red, nir)
306+
assert result.shape == wide_data.shape[:2]
307+
r = np.mean(wide_data[:, :, red].astype(float), axis=2)
308+
n = np.mean(wide_data[:, :, nir].astype(float), axis=2)
309+
assert_allclose(result, (n - r) / (n + r))
310+
311+
312+
class TestBdist:
313+
314+
@pytest.fixture
315+
def two_classes(self):
316+
rng = np.random.RandomState(0)
317+
d1 = rng.rand(50, 3)
318+
d2 = rng.rand(50, 3) + 2.0
319+
320+
def make_class(d):
321+
stats = GaussianStats(mean=d.mean(0),
322+
cov=np.cov(d, rowvar=False),
323+
nsamples=d.shape[0])
324+
return SimpleNamespace(stats=stats)
325+
326+
return (make_class(d1), make_class(d2))
327+
328+
def test_bdist_is_sum_of_terms(self, two_classes):
329+
(c1, c2) = two_classes
330+
terms = bdist_terms(c1, c2)
331+
assert_allclose(bdist(c1, c2), terms[0] + terms[1])
332+
333+
def test_bdist_terms_lin_term_zero_for_identical_classes(self,
334+
two_classes):
335+
(c1, _) = two_classes
336+
(lin_term, quad_term) = bdist_terms(c1, c1)
337+
assert_allclose(lin_term, 0.0, atol=1e-10)
338+
assert_allclose(quad_term, 0.0, atol=1e-10)
339+
340+
def test_bdist_positive_for_different_classes(self, two_classes):
341+
(c1, c2) = two_classes
342+
assert bdist(c1, c2) > 0
343+
344+
345+
class TestCovAvg:
346+
347+
@pytest.fixture
348+
def masked_image(self):
349+
rng = np.random.RandomState(0)
350+
img = rng.rand(10, 10, 4)
351+
mask = np.zeros((10, 10), int)
352+
mask[:5, :] = 1
353+
mask[5:, :] = 2
354+
return (img, mask)
355+
356+
def test_weighted_matches_manual_calc(self, masked_image):
357+
(img, mask) = masked_image
358+
result = cov_avg(img, mask, weighted=True)
359+
s1 = calc_stats(img, mask, 1)
360+
s2 = calc_stats(img, mask, 2)
361+
N = s1.nsamples + s2.nsamples
362+
expected = (((s1.nsamples - 1) / float(N - 1)) * s1.cov +
363+
((s2.nsamples - 1) / float(N - 1)) * s2.cov)
364+
assert_allclose(result, expected)
365+
366+
def test_unweighted_matches_manual_calc(self, masked_image):
367+
(img, mask) = masked_image
368+
result = cov_avg(img, mask, weighted=False)
369+
s1 = calc_stats(img, mask, 1)
370+
s2 = calc_stats(img, mask, 2)
371+
assert_allclose(result, (s1.cov + s2.cov) / 2)
372+
373+
374+
class TestCovariance:
375+
376+
def test_matches_mean_cov(self, data):
377+
assert_allclose(covariance(data), mean_cov(data)[1])
378+
379+
380+
class TestLogDet:
381+
382+
def test_matches_numpy_for_positive_definite(self):
383+
A = np.array([[4.0, 1.0], [1.0, 3.0]])
384+
assert_allclose(log_det(A), np.log(np.linalg.det(A)))
385+
386+
387+
class TestTransformImageAlgorithms:
388+
'''Tests `spectral.algorithms.algorithms.transform_image`, a distinct
389+
function from (but similar in purpose to) `spyfile.transform_image`.
390+
'''
391+
392+
def test_ndarray_input(self, data):
393+
matrix = np.eye(data.shape[-1]) * 2
394+
result = transform_image(matrix, data)
395+
assert_allclose(result, data * 2)
396+
397+
def test_spyfile_input_returns_transformed_image(self, av3c_image):
398+
matrix = np.eye(av3c_image.nbands)
399+
result = transform_image(matrix, av3c_image)
400+
assert isinstance(result, TransformedImage)
401+
402+
def test_unrecognized_type_raises(self, data):
403+
matrix = np.eye(data.shape[-1])
404+
with pytest.raises(TypeError):
405+
transform_image(matrix, 'not an image')
406+
407+
408+
class TestUnmix:
409+
410+
def test_recovers_pure_and_mixed_pixels(self):
411+
endmembers = np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])
412+
data = np.zeros((1, 3, 3))
413+
data[0, 0] = endmembers[0]
414+
data[0, 1] = endmembers[1]
415+
data[0, 2] = 0.5 * endmembers[0] + 0.5 * endmembers[1]
416+
result = unmix(data, endmembers)
417+
assert_allclose(result[0, 0], [1.0, 0.0], atol=1e-10)
418+
assert_allclose(result[0, 1], [0.0, 1.0], atol=1e-10)
419+
assert_allclose(result[0, 2], [0.5, 0.5], atol=1e-10)
420+
421+
def test_dimension_mismatch_raises(self):
422+
endmembers = np.zeros((2, 3))
423+
data = np.zeros((1, 1, 5))
424+
with pytest.raises(AssertionError):
425+
unmix(data, endmembers)
426+
427+
428+
class TestSpectralAngles:
429+
430+
def test_same_direction_is_zero(self):
431+
data = np.zeros((1, 2, 3))
432+
data[0, 0] = [2.0, 0.0, 0.0] # same direction as member, scaled
433+
data[0, 1] = [0.0, 0.0, 3.0] # orthogonal to member
434+
members = np.array([[1.0, 0.0, 0.0]])
435+
angles = spectral_angles(data, members)
436+
assert_allclose(angles[0, 0, 0], 0.0, atol=1e-10)
437+
assert_allclose(angles[0, 1, 0], np.pi / 2)
438+
439+
def test_dimension_mismatch_raises(self):
440+
members = np.zeros((2, 3))
441+
data = np.zeros((1, 1, 5))
442+
with pytest.raises(AssertionError):
443+
spectral_angles(data, members)
444+
445+
446+
class TestMsam:
447+
448+
def test_self_similarity_is_one(self):
449+
data = np.zeros((1, 1, 4))
450+
data[0, 0] = [1.0, 5.0, 2.0, 8.0]
451+
member = np.array([[1.0, 5.0, 2.0, 8.0]])
452+
result = msam(data, member)
453+
assert_allclose(result[0, 0, 0], 1.0)
454+
455+
def test_matches_manual_formula(self):
456+
'''`msam` mean-centers and normalizes both the pixel and member
457+
spectra (a "Fisher z"-like transform) before computing the angle,
458+
so it isn't literally the same as the raw spectral angle -- this
459+
replicates its documented formula directly.'''
460+
rng = np.random.RandomState(0)
461+
pixel = rng.rand(5)
462+
member = rng.rand(5)
463+
data = pixel.reshape(1, 1, 5)
464+
members = member.reshape(1, 5)
465+
466+
v = pixel - np.mean(pixel)
467+
v = v / np.sqrt(v.dot(v))
468+
m = member - np.mean(member)
469+
m = m / np.sqrt(m.dot(m))
470+
expected = 1.0 - np.arccos(np.clip(v.dot(m), -1, 1)) / (np.pi / 2)
471+
472+
result = msam(data, members)
473+
assert_allclose(result[0, 0, 0], expected)
474+
475+
def test_dimension_mismatch_raises(self):
476+
members = np.zeros((2, 3))
477+
data = np.zeros((1, 1, 5))
478+
with pytest.raises(AssertionError):
479+
msam(data, members)

0 commit comments

Comments
 (0)