rio-diff 1.0.0a0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rio_diff/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """rio_diff: Raster comparison plugin for the Rasterio CLI."""
2
+
3
+ __version__ = "1.0.0a0"
4
+
5
+ from .compare import compare_rasters # noqa
rio_diff/compare.py ADDED
@@ -0,0 +1,224 @@
1
+ import warnings
2
+ from pathlib import Path
3
+
4
+ import numpy as np
5
+ import rasterio
6
+
7
+ from rio_diff import models, utils
8
+
9
+ # Ограничение блок-кэша GDAL. По умолчанию GDAL отводит под кэш ~5% ОЗУ, из-за
10
+ # чего сквозной обход всех тайлов растра раздувает потребление памяти до
11
+ # нескольких ГБ. Каждый тайл здесь читается ровно один раз, поэтому кэш
12
+ # бесполезен и его можно держать маленьким.
13
+ GDAL_CACHEMAX_BYTES = 256 * 1024 * 1024
14
+
15
+
16
+ def read_raster_props(inp_file: str, *, read_stats: bool = True) -> models.RasterProps:
17
+ with rasterio.open(inp_file) as ds:
18
+ return models.RasterProps(
19
+ width=ds.profile["width"],
20
+ height=ds.profile["height"],
21
+ bands=ds.profile["count"],
22
+ dtype=ds.profile["dtype"],
23
+ nodata=ds.profile["nodata"], # TODO: проверка для разных каналов
24
+ bbox=ds.bounds,
25
+ crs=ds.profile["crs"],
26
+ transform=ds.profile["transform"],
27
+ metadata=ds.tags(),
28
+ bands_metadata=[ds.tags(bidx=bidx) for bidx in range(1, ds.count + 1)],
29
+ stats=ds.stats() if read_stats else [], # TODO: безопаснее будет считать самому по numpy
30
+ )
31
+
32
+
33
+ def is_compatible_rasters(base_raster: str, test_raster: str) -> bool:
34
+ """Проверить, что растры можно сравнить попиксельно.
35
+
36
+ Для попиксельного diff-а массивы должны совпадать по форме, поэтому
37
+ проверяется только то, без чего вычитание невозможно:
38
+ - количество ячеек растра (столбцов и строк);
39
+ - количество каналов.
40
+
41
+ Различия в геопривязке (transform) и системе координат (crs) не мешают
42
+ вычитанию массивов и репортятся отдельно, поэтому здесь не учитываются.
43
+ """
44
+ with rasterio.open(base_raster) as base_ds, rasterio.open(test_raster) as test_ds:
45
+ return base_ds.shape == test_ds.shape and base_ds.count == test_ds.count
46
+
47
+
48
+ def calc_diff(
49
+ base_raster: str,
50
+ test_raster: str,
51
+ *,
52
+ rtol=0,
53
+ atol=0,
54
+ equal_nan=True,
55
+ diff_raster_path: str | None = None,
56
+ ) -> list[models.PixelDiffStats]:
57
+ """Вычитать первый растр из второго для получения diff-a и его последующего анализа
58
+ Сколько пикселей отличается, насколько они отличаются и т.п.
59
+ Опционально выводить график (картинку) и возможность сохранения diff-a на диск
60
+ """
61
+ with rasterio.Env(GDAL_CACHEMAX=GDAL_CACHEMAX_BYTES), \
62
+ rasterio.open(base_raster) as base_ds, \
63
+ rasterio.open(test_raster) as test_ds:
64
+ count = base_ds.count
65
+ total_pixels = base_ds.width * base_ds.height
66
+
67
+ nd_base = base_ds.nodatavals
68
+ nd_test = test_ds.nodatavals
69
+
70
+ diff_count = np.zeros(count, dtype=np.int64)
71
+ valid_count = np.zeros(count, dtype=np.int64)
72
+ max_diff = np.zeros(count, dtype=np.float64)
73
+ sum_squared_diff = np.zeros(count, dtype=np.float64)
74
+
75
+ diff_ds = None
76
+ if diff_raster_path is not None:
77
+ diff_profile = base_ds.profile
78
+ diff_profile.update({
79
+ "dtype": "float32",
80
+ "nodata": float("nan"),
81
+ "compress": "deflate",
82
+ "predictor": 3,
83
+ "zlevel": 6,
84
+ })
85
+ Path(diff_raster_path).parent.mkdir(parents=True, exist_ok=True)
86
+ diff_ds = rasterio.open(diff_raster_path, "w", **diff_profile)
87
+
88
+ try:
89
+ # Обрабатываем растр окно за окном (по всем каналам сразу), чтобы не
90
+ # держать весь diff в памяти и читать каждый блок только один раз.
91
+ for _, window in base_ds.block_windows(1):
92
+ arr_base = base_ds.read(window=window).astype("float32")
93
+ arr_test = test_ds.read(window=window).astype("float32")
94
+
95
+ for b in range(count):
96
+ if nd_base[b] is not None:
97
+ arr_base[b][arr_base[b] == nd_base[b]] = np.nan
98
+ if nd_test[b] is not None:
99
+ arr_test[b][arr_test[b] == nd_test[b]] = np.nan
100
+
101
+ arr_diff = arr_base - arr_test
102
+ finite_mask = np.isfinite(arr_diff)
103
+ valid_count += np.count_nonzero(finite_mask, axis=(1, 2))
104
+
105
+ if diff_ds is not None:
106
+ diff_ds.write(arr_diff, window=window)
107
+
108
+ close_mask = np.isclose(arr_base, arr_test, rtol=rtol, atol=atol, equal_nan=equal_nan)
109
+ diff_count += np.count_nonzero(~close_mask, axis=(1, 2))
110
+
111
+ abs_diff = np.abs(arr_diff)
112
+ with warnings.catch_warnings():
113
+ warnings.simplefilter("ignore", RuntimeWarning) # окно целиком из NaN
114
+ band_max = np.nanmax(abs_diff, axis=(1, 2))
115
+ band_max = np.nan_to_num(band_max, nan=0.0)
116
+ max_diff = np.maximum(max_diff, band_max)
117
+
118
+ sum_squared_diff += np.nansum(arr_diff ** 2, axis=(1, 2))
119
+ finally:
120
+ if diff_ds is not None:
121
+ diff_ds.close()
122
+
123
+ return [
124
+ models.PixelDiffStats(
125
+ diff_count=int(diff_count[b]),
126
+ total_count=total_pixels,
127
+ diff_percent=float((diff_count[b] / total_pixels) * 100),
128
+ max_diff=float(max_diff[b]),
129
+ rmse=float(np.sqrt(sum_squared_diff[b] / valid_count[b])) if valid_count[b] else 0.0,
130
+ )
131
+ for b in range(count)
132
+ ]
133
+
134
+
135
+ def compare_rasters(
136
+ base_raster: str,
137
+ test_raster: str,
138
+ *,
139
+ diff_raster_path: str | None = None,
140
+ ignore_pixel_values: bool = False,
141
+ ignore_stats: bool = False,
142
+ ) -> models.RasterDiff | None:
143
+ base_md5 = utils.calc_hash(base_raster)
144
+ test_md5 = utils.calc_hash(test_raster)
145
+
146
+ if base_md5 == test_md5:
147
+ return None
148
+
149
+ # Отключаем GDAL PAM, чтобы чтение статистики (ds.stats()) и запись растров
150
+ # не создавали сайдкар-файлы <растр>.aux.xml рядом с входными данными.
151
+ with rasterio.Env(GDAL_PAM_ENABLED="NO"):
152
+ base_props = read_raster_props(base_raster, read_stats=not ignore_stats)
153
+ test_props = read_raster_props(test_raster, read_stats=not ignore_stats)
154
+
155
+ pixel_values = None
156
+ need_pixel_diff = not ignore_pixel_values or diff_raster_path is not None
157
+ if need_pixel_diff and is_compatible_rasters(base_raster, test_raster):
158
+ pixel_values = calc_diff(
159
+ base_raster, test_raster, diff_raster_path=diff_raster_path
160
+ )
161
+
162
+ return models.RasterDiff(
163
+ checksum=models.DiffStr(
164
+ equal=base_md5 == test_md5,
165
+ base=base_md5,
166
+ test=test_md5,
167
+ ),
168
+ bands=models.DiffInt(
169
+ equal=base_props.bands == test_props.bands,
170
+ base=base_props.bands,
171
+ test=test_props.bands,
172
+ ),
173
+ width=models.DiffInt(
174
+ equal=base_props.width == test_props.width,
175
+ base=base_props.width,
176
+ test=test_props.width,
177
+ ),
178
+ height=models.DiffInt(
179
+ equal=base_props.height == test_props.height,
180
+ base=base_props.height,
181
+ test=test_props.height,
182
+ ),
183
+ dtype=models.DiffStr(
184
+ equal=base_props.dtype == test_props.dtype,
185
+ base=base_props.dtype,
186
+ test=test_props.dtype,
187
+ ),
188
+ nodata=models.DiffOptionalFloat(
189
+ equal=base_props.nodata == test_props.nodata,
190
+ base=base_props.nodata,
191
+ test=test_props.nodata,
192
+ ),
193
+ bbox=models.DiffBbox(
194
+ equal=base_props.bbox == test_props.bbox,
195
+ base=base_props.bbox,
196
+ test=test_props.bbox,
197
+ ),
198
+ crs=models.DiffCRS(
199
+ equal=base_props.crs == test_props.crs,
200
+ base=base_props.crs,
201
+ test=test_props.crs,
202
+ ),
203
+ transform=models.DiffTransform(
204
+ equal=base_props.transform == test_props.transform,
205
+ base=base_props.transform,
206
+ test=test_props.transform,
207
+ ),
208
+ metadata=models.DiffList(
209
+ equal=base_props.metadata == test_props.metadata,
210
+ base=base_props.metadata,
211
+ test=test_props.metadata,
212
+ ),
213
+ bands_metadata=models.DiffList(
214
+ equal=base_props.bands_metadata == test_props.bands_metadata,
215
+ base=base_props.bands_metadata,
216
+ test=test_props.bands_metadata,
217
+ ),
218
+ stats=models.DiffList(
219
+ equal=base_props.stats == test_props.stats,
220
+ base=base_props.stats,
221
+ test=test_props.stats,
222
+ ),
223
+ pixel_values=pixel_values,
224
+ )
rio_diff/models.py ADDED
@@ -0,0 +1,96 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+ from affine import Affine
5
+ from rasterio.coords import BoundingBox
6
+ from rasterio.crs import CRS
7
+
8
+
9
+ @dataclass
10
+ class RasterProps:
11
+ width: int
12
+ height: int
13
+ bands: int
14
+ dtype: str
15
+ nodata: float | None
16
+ bbox: BoundingBox
17
+ crs: CRS
18
+ transform: Affine
19
+ metadata: list[dict[str, Any]]
20
+ bands_metadata: list
21
+ stats: list
22
+
23
+
24
+ @dataclass
25
+ class DiffStr:
26
+ equal: bool
27
+ base: str
28
+ test: str
29
+
30
+
31
+ @dataclass
32
+ class DiffInt:
33
+ equal: bool
34
+ base: int
35
+ test: int
36
+
37
+
38
+ @dataclass
39
+ class DiffOptionalFloat:
40
+ equal: bool
41
+ base: float | None
42
+ test: float | None
43
+
44
+
45
+ @dataclass
46
+ class DiffList:
47
+ equal: bool
48
+ base: list
49
+ test: list
50
+
51
+
52
+ @dataclass
53
+ class DiffBbox:
54
+ equal: bool
55
+ base: BoundingBox
56
+ test: BoundingBox
57
+
58
+
59
+ @dataclass
60
+ class DiffCRS:
61
+ equal: bool
62
+ base: CRS
63
+ test: CRS
64
+
65
+
66
+ @dataclass
67
+ class DiffTransform:
68
+ equal: bool
69
+ base: Affine
70
+ test: Affine
71
+
72
+
73
+ @dataclass
74
+ class PixelDiffStats:
75
+ diff_count: int
76
+ total_count: int
77
+ diff_percent: float
78
+ max_diff: float
79
+ rmse: float
80
+
81
+
82
+ @dataclass
83
+ class RasterDiff:
84
+ checksum: DiffStr
85
+ bands: DiffInt
86
+ width: DiffInt
87
+ height: DiffInt
88
+ dtype: DiffStr
89
+ nodata: DiffOptionalFloat
90
+ bbox: DiffBbox
91
+ crs: DiffCRS
92
+ transform: DiffTransform
93
+ metadata: DiffList
94
+ bands_metadata: DiffList
95
+ stats: DiffList
96
+ pixel_values: list[PixelDiffStats] | None
rio_diff/render.py ADDED
@@ -0,0 +1,129 @@
1
+ """Вывод отчёта сравнения растров в консоль в стиле pytest-diff.
2
+
3
+ Показываются только различия. Каждое поле выводится единым способом — как
4
+ pytest показывает `assert base == test`: unified-diff по ``pprint``-представлению,
5
+ где строки ``-`` относятся к base (красный), ``+`` — к test (зелёный), а
6
+ неизменившиеся строки служат контекстом.
7
+ """
8
+
9
+ import difflib
10
+ import pprint
11
+
12
+ import click
13
+ from affine import Affine
14
+
15
+ from rio_diff import models
16
+
17
+ _STAT_ATTRS = ("min", "max", "mean", "std")
18
+ _TRANSFORM_ATTRS = ("a", "b", "c", "d", "e", "f")
19
+
20
+
21
+ def _has_attrs(value, attrs) -> bool:
22
+ return all(hasattr(value, attr) for attr in attrs)
23
+
24
+
25
+ def _prepare(value):
26
+ """Разложить объекты по полям, чтобы ``pprint`` печатал их построчно.
27
+
28
+ Statistics/BBox/Transform и подобные объекты в repr-е выглядят одной
29
+ строкой, поэтому diff краснит их целиком. Превращаем их в dict по полям
30
+ (рекурсивно), тогда каждое поле оказывается на своей строке и в diff-е
31
+ подсвечивается только изменившееся. Ключи внешних dict сортируем, чтобы
32
+ base и test были выровнены.
33
+ """
34
+ if isinstance(value, dict):
35
+ return {key: _prepare(value[key]) for key in sorted(value, key=str)}
36
+ if isinstance(value, Affine):
37
+ return {name: getattr(value, name) for name in _TRANSFORM_ATTRS}
38
+ if _has_attrs(value, _STAT_ATTRS):
39
+ return {name: getattr(value, name) for name in _STAT_ATTRS}
40
+ if hasattr(value, "_fields"): # namedtuple, напр. BoundingBox
41
+ return {name: _prepare(getattr(value, name)) for name in value._fields}
42
+ if isinstance(value, (list, tuple)):
43
+ return [_prepare(item) for item in value]
44
+ return value
45
+
46
+
47
+ def _lines(value) -> list[str]:
48
+ """Разбить значение на строки для diff-а.
49
+
50
+ Контейнеры раскладываем через ``pprint`` с узкой шириной, чтобы элементы
51
+ ложились по одному на строку. Скаляры берём через ``str`` — так у объектов
52
+ с коротким представлением (например, CRS → ``EPSG:32637``) не всплывает
53
+ громоздкий repr.
54
+ """
55
+ prepared = _prepare(value)
56
+ if isinstance(prepared, (dict, list, tuple)):
57
+ text = pprint.pformat(prepared, width=1, sort_dicts=False)
58
+ else:
59
+ text = str(prepared)
60
+ return text.splitlines() or [text]
61
+
62
+
63
+ def _print_value_diff(base, test) -> None:
64
+ for line in difflib.ndiff(_lines(base), _lines(test)):
65
+ tag = line[:2]
66
+ if tag == "? ": # подсказки ndiff с ^~+ — пропускаем как шум
67
+ continue
68
+ if tag == "- ":
69
+ click.secho(f" {line}", fg="red")
70
+ elif tag == "+ ":
71
+ click.secho(f" {line}", fg="green")
72
+ else: # " " — общий контекст
73
+ click.echo(f" {line}")
74
+
75
+
76
+ def _print_mismatch(label: str, base, test) -> None:
77
+ click.secho(label, bold=True)
78
+ _print_value_diff(base, test)
79
+
80
+
81
+ def print_report(
82
+ checks: list[tuple[str, bool, object, object]],
83
+ pixel_values: list[models.PixelDiffStats] | None,
84
+ show_pixel_values: bool,
85
+ ) -> bool:
86
+ """Вывести различия. Возвращает True, если найдено хотя бы одно."""
87
+ printed = 0
88
+
89
+ def separate() -> None:
90
+ nonlocal printed
91
+ if printed:
92
+ click.echo()
93
+ printed += 1
94
+
95
+ for label, equal, base, test in checks:
96
+ if not equal:
97
+ separate()
98
+ _print_mismatch(label, base, test)
99
+
100
+ if show_pixel_values:
101
+ if pixel_values is None:
102
+ separate()
103
+ click.secho(
104
+ "Pixel values not compared (incompatible shape or band count)",
105
+ fg="yellow",
106
+ )
107
+ else:
108
+ diffs = [
109
+ (bidx, stat)
110
+ for bidx, stat in enumerate(pixel_values, start=1)
111
+ if stat.diff_count > 0
112
+ ]
113
+ if diffs:
114
+ separate()
115
+ click.secho("Pixel values", bold=True)
116
+ rows = [
117
+ {
118
+ "band": bidx,
119
+ "diff_count": stat.diff_count,
120
+ "diff_percent": round(stat.diff_percent, 2),
121
+ "max_diff": stat.max_diff,
122
+ "rmse": stat.rmse,
123
+ }
124
+ for bidx, stat in diffs
125
+ ]
126
+ for line in _lines(rows):
127
+ click.secho(f" {line}", fg="red")
128
+
129
+ return printed > 0
File without changes
@@ -0,0 +1,162 @@
1
+ import click
2
+
3
+ from rio_diff import __version__ as plugin_version, render
4
+ from rio_diff.compare import compare_rasters
5
+
6
+
7
+ @click.command("diff", short_help="Compare rasters")
8
+ @click.argument("base_raster", type=click.Path(exists=True))
9
+ @click.argument("test_raster", type=click.Path(exists=True))
10
+ @click.option(
11
+ "--ignore-height",
12
+ default=False,
13
+ is_flag=True,
14
+ help="The number of height will be ignored.",
15
+ show_default=True,
16
+ )
17
+ @click.option(
18
+ "--ignore-width",
19
+ default=False,
20
+ is_flag=True,
21
+ help="The number of width will be ignored.",
22
+ show_default=True,
23
+ )
24
+ @click.option(
25
+ "--ignore-bands",
26
+ default=False,
27
+ is_flag=True,
28
+ help="The number of bands will be ignored.",
29
+ show_default=True,
30
+ )
31
+ @click.option(
32
+ "--ignore-dtype",
33
+ default=False,
34
+ is_flag=True,
35
+ help="Data type will be ignored.",
36
+ show_default=True,
37
+ )
38
+ @click.option(
39
+ "--ignore-nodata",
40
+ default=False,
41
+ is_flag=True,
42
+ help="NoData values will be ignored.",
43
+ show_default=True,
44
+ )
45
+ @click.option(
46
+ "--ignore-bbox",
47
+ default=False,
48
+ is_flag=True,
49
+ help="Bounding box will be ignored.",
50
+ show_default=True,
51
+ )
52
+ @click.option(
53
+ "--ignore-crs",
54
+ default=False,
55
+ is_flag=True,
56
+ help="Coordinate reference system will be ignored.",
57
+ show_default=True,
58
+ )
59
+ @click.option(
60
+ "--ignore-transform",
61
+ default=False,
62
+ is_flag=True,
63
+ help="Affine transform will be ignored.",
64
+ show_default=True,
65
+ )
66
+ @click.option(
67
+ "--ignore-metadata",
68
+ default=False,
69
+ is_flag=True,
70
+ help="Metadata will be ignored.",
71
+ show_default=True,
72
+ )
73
+ @click.option(
74
+ "--ignore-stats",
75
+ default=False,
76
+ is_flag=True,
77
+ help="Statistics will be ignored.",
78
+ show_default=True,
79
+ )
80
+ @click.option(
81
+ "--ignore-pixels",
82
+ "ignore_pixel_values",
83
+ default=False,
84
+ is_flag=True,
85
+ help="Pixel values will be ignored.",
86
+ show_default=True,
87
+ )
88
+ @click.option(
89
+ "--checksum",
90
+ "check_checksum",
91
+ default=False,
92
+ is_flag=True,
93
+ help="Also compare the whole-file checksum (strict byte-level equality).",
94
+ show_default=True,
95
+ )
96
+ @click.option(
97
+ "--save-diff",
98
+ "save_diff",
99
+ type=click.Path(),
100
+ default=None,
101
+ help="Save the per-pixel difference raster (base - test) to the given path.",
102
+ )
103
+ @click.version_option(version=plugin_version, message="%(version)s")
104
+ @click.pass_context
105
+ def diff(
106
+ ctx,
107
+ base_raster,
108
+ test_raster,
109
+ ignore_height,
110
+ ignore_width,
111
+ ignore_bands,
112
+ ignore_dtype,
113
+ ignore_nodata,
114
+ ignore_bbox,
115
+ ignore_crs,
116
+ ignore_transform,
117
+ ignore_metadata,
118
+ ignore_stats,
119
+ ignore_pixel_values,
120
+ check_checksum,
121
+ save_diff,
122
+ ):
123
+ """Rasterio diff plugin.
124
+ """
125
+ report = compare_rasters(
126
+ base_raster,
127
+ test_raster,
128
+ diff_raster_path=save_diff,
129
+ ignore_pixel_values=ignore_pixel_values,
130
+ ignore_stats=ignore_stats,
131
+ )
132
+
133
+ if report is None:
134
+ ctx.exit(0)
135
+
136
+ checks: list[tuple[str, bool, object, object]] = []
137
+
138
+ def add(ignore: bool, diff, label: str) -> None:
139
+ if not ignore:
140
+ checks.append((label, diff.equal, diff.base, diff.test))
141
+
142
+ add(not check_checksum, report.checksum, "Checksum")
143
+ add(ignore_width, report.width, "Width")
144
+ add(ignore_height, report.height, "Height")
145
+ add(ignore_bands, report.bands, "Bands")
146
+ add(ignore_dtype, report.dtype, "Data type")
147
+ add(ignore_nodata, report.nodata, "NoData")
148
+ add(ignore_bbox, report.bbox, "BBox")
149
+ add(ignore_crs, report.crs, "CRS")
150
+ add(ignore_transform, report.transform, "Transform")
151
+ add(ignore_metadata, report.metadata, "Metadata")
152
+ # TODO: выводить конкретно в чем разница и для какого канала
153
+ add(ignore_metadata, report.bands_metadata, "Bands metadata")
154
+ # TODO: выводить детально в каких каналах и в чем различия
155
+ add(ignore_stats, report.stats, "Statistics")
156
+
157
+ has_diff = render.print_report(
158
+ checks,
159
+ report.pixel_values,
160
+ show_pixel_values=not ignore_pixel_values,
161
+ )
162
+ ctx.exit(1 if has_diff else 0)
rio_diff/utils.py ADDED
@@ -0,0 +1,11 @@
1
+ import hashlib
2
+
3
+
4
+ def calc_hash(inp_file: str) -> str:
5
+ hash = hashlib.md5()
6
+
7
+ with open(inp_file, "rb") as file:
8
+ for chunk in iter(lambda: file.read(4096), b""):
9
+ hash.update(chunk)
10
+
11
+ return hash.hexdigest()
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: rio-diff
3
+ Version: 1.0.0a0
4
+ Summary: Raster comparison plugin for rasterio
5
+ Project-URL: Homepage, https://github.com/perminovsi/rio-diff
6
+ Author-email: Sergey Perminov <perminovsi@ya.ru>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: raster,rasterio,rio-plugin
10
+ Classifier: Intended Audience :: Information Technology
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: GIS
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: click>=7.0
16
+ Requires-Dist: numpy>=2.0
17
+ Requires-Dist: rasterio>=1.3
18
+ Description-Content-Type: text/markdown
19
+
20
+ # rio-diff
21
+
22
+ A rasterio plugin for comparing two raster files and showing differences between them.
23
+
24
+ ## Overview
25
+
26
+ `rio-diff` is a command-line tool that extends the functionality of [Rasterio](https://github.com/rasterio/rasterio) by providing a way to compare two raster files and highlight their differences. Inspired by the classic Unix `diff` command, this plugin enables users to identify differences in raster properties and pixel values.
27
+
28
+ ## Features
29
+
30
+ - Compare various raster properties including dimensions, data types, coordinate reference systems, and metadata
31
+ - Calculate pixel-by-pixel differences between compatible rasters
32
+ - Optionally save the per-pixel difference raster (`base - test`) to disk
33
+ - Show statistics on differences including count, percentage, maximum difference, and RMSE
34
+ - Support for ignoring specific properties during comparison
35
+ - Integration with Rasterio's command-line interface
36
+
37
+ ## Installation
38
+
39
+ Install the package using pip:
40
+
41
+ ```bash
42
+ pip install rio-diff
43
+ ```
44
+
45
+ After installation, the plugin will be available as a subcommand of `rio`.
46
+
47
+ ## Usage
48
+
49
+ Basic usage to compare two raster files:
50
+
51
+ ```bash
52
+ rio diff base_raster.tif test_raster.tif
53
+ ```
54
+
55
+ ### Options
56
+
57
+ - `--ignore-height`: Ignore the height property during comparison
58
+ - `--ignore-width`: Ignore the width property during comparison
59
+ - `--ignore-bands`: Ignore the number of bands during comparison
60
+ - `--ignore-dtype`: Ignore data type during comparison
61
+ - `--ignore-nodata`: Ignore NoData values during comparison
62
+ - `--ignore-bbox`: Ignore bounding box during comparison
63
+ - `--ignore-crs`: Ignore coordinate reference system during comparison
64
+ - `--ignore-transform`: Ignore affine transform during comparison
65
+ - `--ignore-metadata`: Ignore metadata during comparison
66
+ - `--ignore-stats`: Ignore statistics during comparison
67
+ - `--ignore-pixels`: Ignore pixel values during comparison
68
+ - `--checksum`: Also compare the whole-file checksum (strict byte-level equality; optional, off by default)
69
+ - `--save-diff PATH`: Save the per-pixel difference raster (`base - test`) to the given path. When the rasters are byte-identical, the tool exits early and no diff raster is written.
70
+ - `--version`: Show version information
71
+
72
+ ### Examples
73
+
74
+ Compare two raster files with default settings:
75
+
76
+ ```bash
77
+ rio diff raster1.tif raster2.tif
78
+ ```
79
+
80
+ Ignore metadata differences when comparing:
81
+
82
+ ```bash
83
+ rio diff raster1.tif raster2.tif --ignore-metadata
84
+ ```
85
+
86
+ Compare rasters but ignore differences in pixel values:
87
+
88
+ ```bash
89
+ rio diff raster1.tif raster2.tif --ignore-pixels
90
+ ```
91
+
92
+ Require strict byte-level equality via the whole-file checksum:
93
+
94
+ ```bash
95
+ rio diff raster1.tif raster2.tif --checksum
96
+ ```
97
+
98
+ Save the per-pixel difference raster to disk:
99
+
100
+ ```bash
101
+ rio diff raster1.tif raster2.tif --save-diff diff.tif
102
+ ```
103
+
104
+ If the rasters are byte-identical, the tool exits early and the diff raster is not written.
105
+
106
+ ## Comparison Details
107
+
108
+ The tool compares the following raster properties:
109
+
110
+ - **Checksum**: MD5 hash of the file content (only when `--checksum` is passed).
111
+ - **Dimensions**: Width and height in pixels
112
+ - **Bands**: Number of channels/layers
113
+ - **Data Type**: Bit depth and signed/unsigned nature
114
+ - **NoData Value**: Value representing missing or invalid data
115
+ - **Bounding Box**: Spatial extent in coordinate units
116
+ - **CRS**: Coordinate Reference System
117
+ - **Transform**: Affine transformation matrix
118
+ - **Metadata**: Tags and attributes associated with the raster
119
+ - **Statistics**: Basic statistical information about pixel values
120
+ - **Pixel Values**: Actual pixel-by-pixel comparison (when rasters are compatible)
121
+
122
+ For compatible rasters, the tool calculates detailed statistics about pixel differences including:
123
+ - Count of different pixels
124
+ - Percentage of different pixels
125
+ - Maximum difference value
126
+ - Root Mean Square Error (RMSE)
127
+
128
+ ## Exit Codes
129
+
130
+ The command sets its exit code so it can be used in scripts and CI:
131
+
132
+ - `0`: No differences were found across the compared properties (also returned early when the files are byte-identical).
133
+ - `1`: At least one difference was found.
134
+ - `2`: Usage error (invalid arguments or missing input files).
135
+
136
+ Ignored properties (`--ignore-*`) do not affect the exit code. The whole-file checksum only affects it when `--checksum` is passed.
137
+
138
+ ## Inspiration
139
+
140
+ - [Linux diff command](https://man7.org/linux/man-pages/man1/diff.1.html) - Classic Unix utility for comparing files
141
+ - [ArcGIS Raster Compare](https://pro.arcgis.com/en/pro-app/latest/tool-reference/data-management/raster-compare.htm) - Tool for comparing raster datasets
142
+
143
+ ## License
144
+
145
+ This project is licensed under the MIT License - see the LICENSE file for details.
@@ -0,0 +1,12 @@
1
+ rio_diff/__init__.py,sha256=zZ75XSqurg7ftuoAjQmYmvUJbxMmpzn8N-_gurMDjg8,134
2
+ rio_diff/compare.py,sha256=xyF46dCdUZV_MEu7XYs0b4wp8TtByk3sZFKyCrCErgg,9454
3
+ rio_diff/models.py,sha256=4aVucjlq4vGd6AiZv7m8R3UxZb0lZRlAveRd4kYYDyY,1447
4
+ rio_diff/render.py,sha256=-X0Ew7LqKaopxQdZgQ3uqIsWulrZVwLhxfqsBJGVFSA,5051
5
+ rio_diff/utils.py,sha256=_VRlUMV9MiWYbuDYf6xTa9Yp2pOEFDqygMQ07y2-isU,236
6
+ rio_diff/scripts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ rio_diff/scripts/cli.py,sha256=g8Rk_RQe39MvZSA4n6lvFjR4Xo2rWyMToZoIEDvUiho,4147
8
+ rio_diff-1.0.0a0.dist-info/METADATA,sha256=kCJGwTFda-X9wHpg75vCiPxddXSsHfWvp4wvICqkLSQ,5273
9
+ rio_diff-1.0.0a0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
10
+ rio_diff-1.0.0a0.dist-info/entry_points.txt,sha256=pu2fRwCS3z9zldLWmz0SSP_ImAMSKqDVmCF8LjHMJfA,56
11
+ rio_diff-1.0.0a0.dist-info/licenses/LICENSE,sha256=4AoS2X3NFixtLlftb0GTuVAe_NzCCTZq5NaLSP8cQb8,1082
12
+ rio_diff-1.0.0a0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [rasterio.rio_plugins]
2
+ diff = rio_diff.scripts.cli:diff
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Sergey Perminov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.