rio-diff 1.0.0a0__tar.gz → 1.0.dev1__tar.gz

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.
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: rio-diff
3
+ Version: 1.0.dev1
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.
@@ -0,0 +1,3 @@
1
+ # rio-diff
2
+
3
+ A rasterio plugin for comparing two raster files and showing differences between them.
@@ -1,5 +1,5 @@
1
1
  """rio_diff: Raster comparison plugin for the Rasterio CLI."""
2
2
 
3
- __version__ = "1.0.0a0"
3
+ __version__ = "1.0.dev1"
4
4
 
5
5
  from .compare import compare_rasters # noqa
@@ -0,0 +1,243 @@
1
+ import hashlib
2
+ from dataclasses import dataclass
3
+ from typing import Any
4
+
5
+ import numpy as np
6
+ import rasterio
7
+ from affine import Affine
8
+ from rasterio.coords import BoundingBox
9
+ from rasterio.crs import CRS
10
+
11
+
12
+ @dataclass
13
+ class RasterProps:
14
+ width: int
15
+ height: int
16
+ bands: int
17
+ dtype: str
18
+ nodata: float | None
19
+ bbox: BoundingBox
20
+ crs: CRS
21
+ transform: Affine
22
+ metadata: list[dict[str, Any]]
23
+ bands_metadata: list
24
+ stats: list
25
+
26
+
27
+ def calc_hash(inp_file: str) -> str:
28
+ hash = hashlib.md5()
29
+
30
+ with open(inp_file, "rb") as file:
31
+ for chunk in iter(lambda: file.read(4096), b""):
32
+ hash.update(chunk)
33
+
34
+ return hash.hexdigest()
35
+
36
+
37
+ def read_raster_props(inp_file: str) -> RasterProps:
38
+ with rasterio.open(inp_file) as ds:
39
+ return RasterProps(
40
+ width=ds.profile["width"],
41
+ height=ds.profile["height"],
42
+ bands=ds.profile["count"],
43
+ dtype=ds.profile["dtype"],
44
+ nodata=ds.profile["nodata"], # TODO: проверка для разных каналов
45
+ bbox=ds.bounds,
46
+ crs=ds.profile["crs"],
47
+ transform=ds.profile["transform"],
48
+ metadata=ds.tags(),
49
+ bands_metadata=[ds.tags(bidx=bidx) for bidx in range(1, ds.count + 1)],
50
+ stats=ds.stats(), # TODO: безопаснее будет считать самому по numpy
51
+ )
52
+
53
+
54
+ def calc_diff(base_raster: str, test_raster: str, *, rtol=0, atol=0, equal_nan=True) -> bool:
55
+ """Вычитать первый растр из второго для получения diff-a и его последующего анализа
56
+ Сколько пикселей отличается, насколько они отличаются и т.п.
57
+ Опционально выводить график (картинку) и возможность сохранения diff-a на диск
58
+ """
59
+ with rasterio.open(base_raster) as base, rasterio.open(test_raster) as test:
60
+ if (
61
+ base.count != test.count or
62
+ base.shape != test.shape or
63
+ base.transform != test.transform or
64
+ base.crs != test.crs or
65
+ base.dtypes != test.dtypes
66
+ ):
67
+ return False
68
+
69
+ for bidx in range(1, base.count + 1):
70
+ nd_base= base.nodatavals[bidx - 1] if base.nodatavals else base.nodata
71
+ nd_test = test.nodatavals[bidx - 1] if test.nodatavals else test.nodata
72
+
73
+ for _, window in base.block_windows(bidx):
74
+ arr_base = base.read(bidx, window=window)
75
+ arr_test = test.read(bidx, window=window)
76
+
77
+ if nd_base is not None:
78
+ mask_base = arr_base == nd_base
79
+ arr_base = arr_base.astype("float64", copy=False)
80
+ arr_base[mask_base] = np.nan
81
+ if nd_test is not None:
82
+ mask_test = arr_test == nd_test
83
+ arr_test = arr_test.astype("float64", copy=False)
84
+ arr_test[mask_test] = np.nan
85
+
86
+ if not np.allclose(arr_base, arr_test, rtol=rtol, atol=atol, equal_nan=equal_nan):
87
+ return False
88
+
89
+ return True
90
+
91
+
92
+ def compare_rasters(
93
+ base_raster: str,
94
+ test_raster: str,
95
+ *,
96
+ ignore_height: bool = False,
97
+ ignore_width: bool = False,
98
+ ignore_bands: bool = False,
99
+ ignore_dtype: bool = False,
100
+ ignore_nodata: bool = False,
101
+ ignore_bbox: bool = False,
102
+ ignore_crs: bool = False,
103
+ ignore_transform: bool = False,
104
+ ignore_metadata: bool = False,
105
+ ignore_stats: bool = False,
106
+ ignore_pixel_values: bool = False,
107
+ ) -> bool:
108
+ is_equal = True
109
+ if calc_hash(base_raster) == calc_hash(test_raster):
110
+ return is_equal
111
+
112
+ base_props = read_raster_props(base_raster)
113
+ test_props = read_raster_props(test_raster)
114
+
115
+ if not ignore_height:
116
+ if base_props.height != test_props.height:
117
+ print(f"< height: {base_props.height}")
118
+ print("---")
119
+ print(f"> height: {test_props.height}")
120
+ print("")
121
+ is_equal = False
122
+
123
+ if not ignore_width:
124
+ if base_props.width != test_props.width:
125
+ print(f"< width: {base_props.width}")
126
+ print("---")
127
+ print(f"> width: {test_props.width}")
128
+ print("")
129
+ is_equal = False
130
+
131
+ if not ignore_bands:
132
+ if base_props.bands != test_props.bands:
133
+ print(f"< bands: {base_props.bands}")
134
+ print("---")
135
+ print(f"> bands: {test_props.bands}")
136
+ print("")
137
+ is_equal = False
138
+
139
+ if not ignore_dtype:
140
+ if base_props.dtype != test_props.dtype:
141
+ print(f"< dtype: {base_props.dtype}")
142
+ print("---")
143
+ print(f"> dtype: {test_props.dtype}")
144
+ print("")
145
+ is_equal = False
146
+
147
+ if not ignore_nodata:
148
+ if base_props.nodata != test_props.nodata:
149
+ print(f"< nodata: {base_props.nodata}")
150
+ print("---")
151
+ print(f"> nodata: {test_props.nodata}")
152
+ print("")
153
+ is_equal = False
154
+
155
+ if not ignore_bbox:
156
+ if base_props.bbox != test_props.bbox:
157
+ print(f"< bbox: {base_props.bbox}")
158
+ print("---")
159
+ print(f"> bbox: {test_props.bbox}")
160
+ print("")
161
+ is_equal = False
162
+
163
+ if not ignore_crs:
164
+ if base_props.crs != test_props.crs:
165
+ print(f"< crs: {base_props.crs}")
166
+ print("---")
167
+ print(f"> crs: {test_props.crs}")
168
+ print("")
169
+ is_equal = False
170
+
171
+ if not ignore_transform:
172
+ if base_props.transform != test_props.transform:
173
+ print(f"< geotransform: {base_props.transform}")
174
+ print("---")
175
+ print(f"> geotransform: {test_props.transform}")
176
+ print("")
177
+ is_equal = False
178
+
179
+ if not ignore_metadata:
180
+ # TODO: выводить конкретно в чем разница и для какого канала
181
+ if base_props.metadata != test_props.metadata:
182
+ print(f"< metadata: {base_props.metadata}")
183
+ print("---")
184
+ print(f"> metadata: {test_props.metadata}")
185
+ print("")
186
+ is_equal = False
187
+
188
+ if base_props.bands_metadata != test_props.bands_metadata:
189
+ print(f"< bands metadata: {base_props.bands_metadata}")
190
+ print("---")
191
+ print(f"> bands metadata: {test_props.bands_metadata}")
192
+ print("")
193
+ is_equal = False
194
+
195
+ if not ignore_stats:
196
+ if base_props.stats != test_props.stats:
197
+ # TODO: выводить детально в каких каналах и в чем различия
198
+ print(f"< statistics: {base_props.stats}")
199
+ print("---")
200
+ print(f"> statistics: {test_props.stats}")
201
+ print("")
202
+ is_equal = False
203
+
204
+ if not ignore_pixel_values:
205
+ if calc_diff(base_raster, test_raster) is False:
206
+ print("< pixel values: ...") # TODO: добавить подробный вывод в чем отличия у пикселей
207
+ print("---")
208
+ print("> pixel values: ...")
209
+ print("")
210
+ is_equal = False
211
+
212
+ return is_equal
213
+
214
+
215
+ if __name__ == "__main__":
216
+ # rs = calc_hash("temp/2025110312/gfs.2025110312.003.cape_180-0.tif")
217
+
218
+ # rs = read_raster_props("temp/icon/OLD.icon.2026010200.003.t_2m.tif")
219
+
220
+ # rs = calc_diff(
221
+ # "temp/icon/OLD.icon.2026010200.003.t_2m.tif",
222
+ # # "temp/icon/NEW.icon.2026010200.003.t_2m.tif",
223
+ # "temp/icon/OLD.icon.2026010200.003.td_2m.tif",
224
+ # )
225
+
226
+ rs = compare_rasters(
227
+ "temp/icon/OLD.icon.2026010200.003.t_2m.tif",
228
+ "temp/icon/NEW.icon.2026010200.003.t_2m.tif",
229
+ # "temp/icon/OLD.icon.2026010200.003.td_2m.tif",
230
+ ignore_height=False,
231
+ ignore_width=False,
232
+ ignore_bands=False,
233
+ ignore_dtype=False,
234
+ ignore_nodata=False,
235
+ ignore_bbox=False,
236
+ ignore_crs=False,
237
+ ignore_transform=False,
238
+ ignore_metadata=True,
239
+ ignore_stats=False,
240
+ ignore_pixel_values=False,
241
+ )
242
+
243
+ print(rs)
@@ -1,6 +1,6 @@
1
1
  import click
2
2
 
3
- from rio_diff import __version__ as plugin_version, render
3
+ from rio_diff import __version__ as plugin_version
4
4
  from rio_diff.compare import compare_rasters
5
5
 
6
6
 
@@ -39,7 +39,7 @@ from rio_diff.compare import compare_rasters
39
39
  "--ignore-nodata",
40
40
  default=False,
41
41
  is_flag=True,
42
- help="NoData values will be ignored.",
42
+ help="No data values will be ignored.",
43
43
  show_default=True,
44
44
  )
45
45
  @click.option(
@@ -78,28 +78,12 @@ from rio_diff.compare import compare_rasters
78
78
  show_default=True,
79
79
  )
80
80
  @click.option(
81
- "--ignore-pixels",
82
- "ignore_pixel_values",
81
+ "--ignore-pixel-values",
83
82
  default=False,
84
83
  is_flag=True,
85
84
  help="Pixel values will be ignored.",
86
85
  show_default=True,
87
86
  )
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
87
  @click.version_option(version=plugin_version, message="%(version)s")
104
88
  @click.pass_context
105
89
  def diff(
@@ -117,46 +101,21 @@ def diff(
117
101
  ignore_metadata,
118
102
  ignore_stats,
119
103
  ignore_pixel_values,
120
- check_checksum,
121
- save_diff,
122
104
  ):
123
105
  """Rasterio diff plugin.
124
106
  """
125
- report = compare_rasters(
126
- base_raster,
127
- test_raster,
128
- diff_raster_path=save_diff,
129
- ignore_pixel_values=ignore_pixel_values,
107
+ compare_rasters(
108
+ base_raster=base_raster,
109
+ test_raster=test_raster,
110
+ ignore_height=ignore_height,
111
+ ignore_width=ignore_width,
112
+ ignore_bands=ignore_bands,
113
+ ignore_dtype=ignore_dtype,
114
+ ignore_nodata=ignore_nodata,
115
+ ignore_bbox=ignore_bbox,
116
+ ignore_crs=ignore_crs,
117
+ ignore_transform=ignore_transform,
118
+ ignore_metadata=ignore_metadata,
130
119
  ignore_stats=ignore_stats,
120
+ ignore_pixel_values=ignore_pixel_values,
131
121
  )
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-1.0.0a0/PKG-INFO DELETED
@@ -1,145 +0,0 @@
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.
@@ -1,126 +0,0 @@
1
- # rio-diff
2
-
3
- A rasterio plugin for comparing two raster files and showing differences between them.
4
-
5
- ## Overview
6
-
7
- `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.
8
-
9
- ## Features
10
-
11
- - Compare various raster properties including dimensions, data types, coordinate reference systems, and metadata
12
- - Calculate pixel-by-pixel differences between compatible rasters
13
- - Optionally save the per-pixel difference raster (`base - test`) to disk
14
- - Show statistics on differences including count, percentage, maximum difference, and RMSE
15
- - Support for ignoring specific properties during comparison
16
- - Integration with Rasterio's command-line interface
17
-
18
- ## Installation
19
-
20
- Install the package using pip:
21
-
22
- ```bash
23
- pip install rio-diff
24
- ```
25
-
26
- After installation, the plugin will be available as a subcommand of `rio`.
27
-
28
- ## Usage
29
-
30
- Basic usage to compare two raster files:
31
-
32
- ```bash
33
- rio diff base_raster.tif test_raster.tif
34
- ```
35
-
36
- ### Options
37
-
38
- - `--ignore-height`: Ignore the height property during comparison
39
- - `--ignore-width`: Ignore the width property during comparison
40
- - `--ignore-bands`: Ignore the number of bands during comparison
41
- - `--ignore-dtype`: Ignore data type during comparison
42
- - `--ignore-nodata`: Ignore NoData values during comparison
43
- - `--ignore-bbox`: Ignore bounding box during comparison
44
- - `--ignore-crs`: Ignore coordinate reference system during comparison
45
- - `--ignore-transform`: Ignore affine transform during comparison
46
- - `--ignore-metadata`: Ignore metadata during comparison
47
- - `--ignore-stats`: Ignore statistics during comparison
48
- - `--ignore-pixels`: Ignore pixel values during comparison
49
- - `--checksum`: Also compare the whole-file checksum (strict byte-level equality; optional, off by default)
50
- - `--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.
51
- - `--version`: Show version information
52
-
53
- ### Examples
54
-
55
- Compare two raster files with default settings:
56
-
57
- ```bash
58
- rio diff raster1.tif raster2.tif
59
- ```
60
-
61
- Ignore metadata differences when comparing:
62
-
63
- ```bash
64
- rio diff raster1.tif raster2.tif --ignore-metadata
65
- ```
66
-
67
- Compare rasters but ignore differences in pixel values:
68
-
69
- ```bash
70
- rio diff raster1.tif raster2.tif --ignore-pixels
71
- ```
72
-
73
- Require strict byte-level equality via the whole-file checksum:
74
-
75
- ```bash
76
- rio diff raster1.tif raster2.tif --checksum
77
- ```
78
-
79
- Save the per-pixel difference raster to disk:
80
-
81
- ```bash
82
- rio diff raster1.tif raster2.tif --save-diff diff.tif
83
- ```
84
-
85
- If the rasters are byte-identical, the tool exits early and the diff raster is not written.
86
-
87
- ## Comparison Details
88
-
89
- The tool compares the following raster properties:
90
-
91
- - **Checksum**: MD5 hash of the file content (only when `--checksum` is passed).
92
- - **Dimensions**: Width and height in pixels
93
- - **Bands**: Number of channels/layers
94
- - **Data Type**: Bit depth and signed/unsigned nature
95
- - **NoData Value**: Value representing missing or invalid data
96
- - **Bounding Box**: Spatial extent in coordinate units
97
- - **CRS**: Coordinate Reference System
98
- - **Transform**: Affine transformation matrix
99
- - **Metadata**: Tags and attributes associated with the raster
100
- - **Statistics**: Basic statistical information about pixel values
101
- - **Pixel Values**: Actual pixel-by-pixel comparison (when rasters are compatible)
102
-
103
- For compatible rasters, the tool calculates detailed statistics about pixel differences including:
104
- - Count of different pixels
105
- - Percentage of different pixels
106
- - Maximum difference value
107
- - Root Mean Square Error (RMSE)
108
-
109
- ## Exit Codes
110
-
111
- The command sets its exit code so it can be used in scripts and CI:
112
-
113
- - `0`: No differences were found across the compared properties (also returned early when the files are byte-identical).
114
- - `1`: At least one difference was found.
115
- - `2`: Usage error (invalid arguments or missing input files).
116
-
117
- Ignored properties (`--ignore-*`) do not affect the exit code. The whole-file checksum only affects it when `--checksum` is passed.
118
-
119
- ## Inspiration
120
-
121
- - [Linux diff command](https://man7.org/linux/man-pages/man1/diff.1.html) - Classic Unix utility for comparing files
122
- - [ArcGIS Raster Compare](https://pro.arcgis.com/en/pro-app/latest/tool-reference/data-management/raster-compare.htm) - Tool for comparing raster datasets
123
-
124
- ## License
125
-
126
- This project is licensed under the MIT License - see the LICENSE file for details.
@@ -1,224 +0,0 @@
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
- )
@@ -1,96 +0,0 @@
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
@@ -1,129 +0,0 @@
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
@@ -1,11 +0,0 @@
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()
File without changes
File without changes
File without changes