|
5 | 5 | # pytest spectral/tests/test_algorithms.py |
6 | 6 | ''' |
7 | 7 |
|
| 8 | +from types import SimpleNamespace |
| 9 | + |
8 | 10 | import numpy as np |
9 | 11 | from numpy.testing import assert_allclose |
10 | 12 | import pytest |
11 | 13 |
|
12 | 14 | import spectral as spy |
13 | 15 | from spectral.algorithms.algorithms import (mean_cov, calc_stats, iterator, |
14 | 16 | 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) |
16 | 22 | from spectral.algorithms.transforms import LinearTransform |
| 23 | +from spectral.io.spyfile import TransformedImage |
17 | 24 | from spectral.utilities.errors import NaNValueError |
18 | 25 |
|
19 | 26 |
|
@@ -267,3 +274,206 @@ def test_num_from_kwargs_rejects_both(self, mnfr): |
267 | 274 | def test_num_from_kwargs_rejects_unknown_kwarg(self, mnfr): |
268 | 275 | with pytest.raises(Exception): |
269 | 276 | 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