Repository navigation
d.legend: Support logarithmic scale for ranges with non-positive values - #8060
Conversation
A logarithmic legend (-l) was a fatal error when the raster range included zero or negative values, although r.colors -g handles such rasters by shifting the range so that its minimum becomes 1. Apply the same shift in d.legend so the legend matches the color table. Set the continuous range of an integer raster before the bar is drawn; the logarithmic bar read it uninitialized before. With -l, label_step labels now stop at the maximum and a range starting at or below zero is labeled at its minimum too.
wenzeslaus
left a comment
There was a problem hiding this comment.
Much needed update!
I find the documentation hard to understand from user perspective. Are my display values now wrong or is that an implementation detail? There seems to be a discrepancy between r.color and the implementation here, but I did not dig into it myself. Overlaps of the labels are visible on the example provided. Do you want to comment on that?
As a side note the use of Tools to create object for rendering is great and it is exactly how I meant that in the sense of Tools being understandable API which can lend itself naturally to many different specializations. Nevertheless, you can consider grass.jupyter.Map which is heavier, but leaves rendering details (variable names) out of the test.
AI-generated review
Here is my AI-boosted analysis. The findings were checked by building this branch's d.legend, running it with and without the PR, and checking label positions with an instrumented build. Line numbers refer to draw.c in this PR.
1. Integer maps: all default labels are drawn on top of each other in a smooth legend. In the !fp branch of the labelnum loop, only tcell is set. The log coef at lines 573 and 603 uses val, which still holds the last value from the bar loop. So every label gets the same position (coef 0.9969 for all of 1, 251, 501, 751, 1001):
g.region rows=100 cols=100 n=100 s=0 e=100 w=0
r.mapcalc "icell = int(1 + row() * col() / 10)"
r.colors -g map=icell color=rainbow
d.legend -l raster=icell at=10,90,10,20
This happens whenever an integer legend is smooth: with -s, with a horizontal legend, or when the map has too many categories. The stale val was already there before this PR, but -l on integer maps was broken in other ways before (empty bar, NaN positions), so nobody saw it. Now that integer maps are supported, the labels are visibly wrong. GCC warns about it: draw.c:603: 'val' may be used uninitialized. One option is to set val = tcell; in that branch.
2. A constant FP raster <= 0 now gives NaN label positions instead of an error. With the fatal check removed, dmin == dmax <= 0 gets log_shift = 1 - dmin, so lmin == lmax == 0 and coef is 0/0 at lines 573, 603, 664 and 747. The labels end up clipped at the top edge. With label_step, the value is also drawn twice: once by the loop, then again by the val = dmax fallback at lines 794-797.
r.mapcalc "c0 = 0.0"
d.legend -l raster=c0 label_step=1
Before this PR, this stopped with Range [0.000, 0.000] out of the logarithm domain. A positive constant (e.g. 5.0) already gave NaN positions in log mode before this PR. A dmin == dmax guard would cover both cases.
3. A positive range with no power of ten in it gets no labels with label_step. For an FP raster from 2 to 9 with -l label_step=1, num = ceil(log10(2)) = 1 (line 731). The first val is 10, so the loop at line 736 never runs and the legend has no labels at all. Before this PR, one label (10.0) was drawn outside the bar. A range of 0.2 to 0.9 behaves the same way. Ranges with values <= 0 get a min/max fallback; positive ranges do not. One option is to use the same fallback when the loop draws nothing.
4. Labels crowd together on shifted ranges. On the mixed test raster (-98 to 100, log_shift 99), d.legend -l label_step=1 places -98 at coef 0.0, 1 at 0.870, 10 at 0.886 and 100 at 1.0. "1" and "10" overlap. The lower 87% of the bar has only the "-98" label, and 0 is never labeled. The test's own label_values=-98,0,10,100 crowds the same way (0 at 0.868, 10 at 0.886). Options:
- Place step labels at powers of ten in shifted space, i.e.
10^k - log_shift(-98, -89, 1, 100 here). - Fall back to evenly spaced labels when
log_shift > 0. - Skip labels that would overlap the previous one.
Smaller notes
- The shift is computed from the legend range (data or
range=), whileRast_log_colors()computes its shift from the color table range. Labels still line up with the colors, because the bar and the labels use exact inverse formulas. Withrange=, though, the gradient on the bar is uneven compared to the color table (e.g. data -10..1000 withr.colors -g, thend.legend -l range=5,1000). The new sentence in the docs, that the legend matches a color table created that way, is only true when the legend range equals the color table range. It may be worth saying so. -l label_step=0(or a negative value) never finishes. This is not new: it happens before this PR too, and linear mode hangs withlabel_step=-1. A check inmain.cthatlabel_stepis positive would fix both modes.
Optional
- The expression
(log10(x + log_shift) - lmin) / (lmax - lmin)is repeated 4 times (lines 573, 603, 664, 747). A smallstatic double log_coef(...)helper gives byte-identical output in local tests. - The tests would not catch issues 3 or 4. A cheap check is to render with
GRASS_RENDER_TRANSPARENT=TRUEat a fixedat=and count non-transparent pixels to the right of the bar using Pillow. This catches "no labels drawn" (issue 3), but not label positions.
|
Addressed except item 4, I am leaning towards this being out of scope. Not sure how to improve more the documentation. |
wenzeslaus
left a comment
There was a problem hiding this comment.
Reading both d.legend and r.colors doc seems to give assurance that the values are taken care of. I looked at couple example legends and the one for negative values does not give expected results if you are expecting log scale just flipped, but this PR matches current r.color behavior, so this is not actionable. There seems to be an issue with a single integer value rasters (edge case for legend but real use case and possible in time series).
AI-generated review
The new dmin == dmax check also applies to integer maps with a single value, where -l worked before:
r.mapcalc "i5 = 5"
d.legend -l raster=i5
Before this PR and with its first commit, this drew a category legend. Now it fails with Range [5.000, 5.000] has no extent for a logarithmic scale. One option is to run the check only where the logarithmic scale is actually used, i.e., for floating-point maps or when the legend is smooth.
wenzeslaus
left a comment
There was a problem hiding this comment.
The images are great! Looking forward to seeing it in the wild!
This is a replacement of #1507, taking the approach implemented by @HuidaeCho in #1937 for r.colors: shifts the range so that its minimum becomes 1 and applies the logarithmic scale to the shifted values.
Example of new behavior:
Developed with Claude Code.