Fix get_area_slices under-crop for cropped geostationary cross-CRS areas#700
Fix get_area_slices under-crop for cropped geostationary cross-CRS areas#700Zaczero wants to merge 1 commit intopytroll:mainfrom
Conversation
|
Wow! I hope to take a look at all your PRs today or at least start. Since this is the first PR I have to ask: did you have all of these fixes/changes sitting around and waited to submit them or did you use AI to find the problems or...how did you have so many problems and have the fixes for them to make so many pull requests in one weekend? |
|
@djhoese I used AI to investigate the problems in my local pipeline, prioritizing correctness bugs and performance bottlenecks. I then reviewed and iterated on fixes (human-in-the-loop). I am not a vibe coder if that's what you're worrying about; otherwise the code would be slop! Plus I am a workaholic. I am new to this project, so obviously I may have missed some things. |
djhoese
left a comment
There was a problem hiding this comment.
Thanks for putting this together. This is a very interesting idea and I've made some comments inline. I think my main concerns are:
- Performance. You have a for loop in Python that is making 2D chunk arrays the size of the destination area.
- The right solution: We (pyresample maintainers) know that the current bounding logic is not good and has a lot of flaws and we have some alternative implementations (PRs and third-party packages) that we want to investigate. I'm wondering if this workaround in this PR should be included because it gets us closer to the answer we want or if this is just another sign that the current implementation needs to be improved.
|
|
||
|
|
||
| def _get_covered_source_slices(src_area: AreaDefinition, area_to_cover: AreaDefinition) -> tuple[slice, slice] | None: | ||
| max_points_per_chunk = 600_000 |
There was a problem hiding this comment.
768*768 is just under 600k. 1024x1024 is 1M. I tested several values and picked the one that in my opinion best matches CPU/memory tradeoffs. 1M was minimally faster for bigger images but used much more memory. Anything lower was not saving much memory and performance got lower.
| destination_lons, destination_lats = area_to_cover.get_lonlats( | ||
| data_slice=(slice(row_start, row_stop), slice(None)), | ||
| dtype=np.float32, | ||
| ) |
There was a problem hiding this comment.
This concerns me efficiency wise. If I'm not mistaken this is making a full 2D array, right? For real-world areas this could 1000s of rows and 1000s of columns per chunk.
This also assumes that the area_to_cover is best represented by longitude/latitude degrees which is not always the case or even not usually the case. It seems like it would be better (although not implemented in the AreaDefinition objects already) to get the X/Y coordinates in the area_to_cover CRS then use pyproj to convert to the X/Y of the src_area then convert those to indices. That way lon/lat are never involved.
There was a problem hiding this comment.
I'm realizing now that even the main boundary code assumes lon/lat so maybe this idea of lon/lat being used is less of a problem for the current implementation.
There was a problem hiding this comment.
As I understand it, the algorithm needs to check every point. This cross-CRS case is now 30% slower. There's some reason why a simple boundary check (not area) would not be correct for every cross-CRS, but I don't remember it now. I'll recheck that and get back to you with concrete information.
There was a problem hiding this comment.
So yeah, for maximum correctness, all points need to be sampled. GDAL samples only edges by default and provides options to enable sampling of all points on a case-by-case basis. It works most of the time, but not always.
Normally when computing the source raster data to load to generate a particular output area, the warper samples transforms 21 points along each edge of the destination region back onto the source file, and uses this to compute a bounding window on the source image that is sufficient. Depending on the transformation in effect, the source window may be a bit too small, or even missing large areas. Problem situations are those where the transformation is very non-linear or "inside out". Examples are transforming from WGS84 to Polar Stereographic for areas around the pole, or transformations where some of the image is untransformable. The following options provide some additional control to deal with errors in computing the source window:
https://gdal.org/en/stable/doxygen/structGDALWarpOptions.html
So the existing implementation equals to SAMPLE_GRID=YES + SAMPLE_STEPS=ALL in GDAL.
Some other libs also use this 21 number by default.
There was a problem hiding this comment.
I implemented the 21 sampled approach with 2 new flags:
sample_steps: int | None = 21,
sample_grid: bool = False,
sample_steps=None, sample_grid=True, would work as the before this patch.
In my case it improved the performance 94ms -> 3ms with minimal numerical drift (<=1px).
| max_col = chunk_max_col if max_col is None else max(max_col, chunk_max_col) | ||
| min_row = chunk_min_row if min_row is None else min(min_row, chunk_min_row) | ||
| max_row = chunk_max_row if max_row is None else max(max_row, chunk_max_row) | ||
| except (RuntimeError, TypeError, ValueError): |
There was a problem hiding this comment.
This worries me that there isn't a clear "this is why this error happens" definition and is instead "catch any problems". I'd like if it was clear why some of these errors are happening and to try to handle them before they happen. Or at the very least comment on why they would happen.
There was a problem hiding this comment.
Yes, I tried to follow project patterns with that one. Such a pattern is very common in the codebase. Generic except catches.
There was a problem hiding this comment.
Such a pattern is very common in the codebase
Yes, this project has never had the strictest of coding rules.
ac5087c to
dc30c4c
Compare
| def _get_edge_lonlat_samples(area_to_cover: AreaDefinition, sample_steps: int): | ||
| """Return perimeter destination samples for edge-only sampling mode.""" |
There was a problem hiding this comment.
Is this different than get_boundary_lonlats?
pyresample/pyresample/geometry.py
Lines 293 to 294 in 07711c0
There was a problem hiding this comment.
Yes. _get_edge_lonlat_samples is geostationary-aware, and sampling follows the visibility curve rather than the image rectangular boundary. get_bbox_lonlats could pick samples that are not the true visible boundary. Plus it's more efficient because it's purpose build.
| min_row = None | ||
| max_row = None | ||
| try: | ||
| src_proj = Proj(src_area.crs) |
There was a problem hiding this comment.
The pyproj Proj class is less preferred these days (deprecated even?) in favor of Transformer. This is an even stronger argument for maybe looking at not using lons/lats as the intermediate value. By that I mean since you have two define the src and dst CRS in a Transformer you might as well go from source area CRS to area_to_cover CRS. At least that's my gut reaction as I look at this.
There was a problem hiding this comment.
Transformer.from_proj is deprecated, but Proj is not. Sample points are in lon/lat units, so Proj is the cleanest API to use in this case: we do lon/lat->CRS, not general CRS->CRS. get_projection_coordinates_from_lonlat uses a similar technique.
There was a problem hiding this comment.
Hm I could have sworn there was an explicit deprecation, but can't find it now. However, Proj is limited datum-wise:
| if sample_steps is None: | ||
| yield from _iter_dense_lonlat_samples(area_to_cover) | ||
| return | ||
| if sample_grid: | ||
| yield _get_grid_lonlat_samples(area_to_cover, sample_steps) | ||
| return | ||
| yield _get_edge_lonlat_samples(area_to_cover, sample_steps) |
There was a problem hiding this comment.
I could be wrong but I don't think this logic matches GDAL. I read the GDAL docs as sample_grid=False and sample_steps being ALL (0/None in our case) that that would do all the pixels along the edges. When sample_grid=True then sample_steps=ALL/None is the full dense grid (edges and all internal points).
dc30c4c to
79d6db6
Compare
|
I've restarted CI as it had a weird failure. Let's see how it does now... |
79d6db6 to
0f47d0f
Compare
0f47d0f to
7c0cc75
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #700 +/- ##
==========================================
+ Coverage 93.67% 93.71% +0.04%
==========================================
Files 89 89
Lines 13621 13863 +242
==========================================
+ Hits 12759 12992 +233
- Misses 862 871 +9
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
AreaDefinition.get_area_slicescan under-crop cropped geostationary cross-CRS areas by merging the existing boundary-based slice with chunked destination-coverage bounds.Before (notice cropping at the bottom)
After