From ea1efb330c6ab1baa08cf241b7ddb8874d191bb9 Mon Sep 17 00:00:00 2001 From: Max Jones <14077947+maxrjones@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:33:43 +0000 Subject: [PATCH 1/8] feat: add Worldview Rainbow 4 and NAQFC colormaps Register custom colormaps from `src/titiler/multidim/cmap_data/*.npy` and expose them through both the tile endpoints (`colormap_dependency`) and `/colorMaps` (`supported_colormaps`), the same wiring veda-backend uses. Three colormaps ship, generated by `scripts/make_cmaps.py` (numpy only): - `worldview_rainbow_4`: NASA Worldview's "Rainbow 4" palette, verbatim from nasa-gibs/worldview (255 colors, top color repeated), for TEMPO NO2 with e.g. `rescale=0,2e16`. - `naqfc_o3`: NOAA Air Quality Forecast Guidance Viewer ozone ramp over 0-200 ppb (`rescale=0,200`, or `0,0.2` for ppm). - `naqfc_pm25`: the ozone ramp stretched so each EPA 24-hour PM2.5 AQI category spans the same colors as its 8-hour ozone category, over 0-225.4 ug/m3 (`rescale=0,225.4`). `tests/test_cmaps.py` regenerates every file and fails if a committed `.npy` differs from the script, checks the Worldview palette is verbatim, and requests a tile and the `/colorMaps` entries through the app. Near-term counterpart to NASA-IMPACT/veda-backend#665 for data served by titiler-multidim. Longer term these move to a shared colormap package. Co-Authored-By: Claude Fable 5.1 --- README.md | 17 ++ pyproject.toml | 1 + scripts/cmap_sources/rainbow_4.json | 263 ++++++++++++++++++ scripts/make_cmaps.py | 91 ++++++ src/titiler/multidim/cmap_data/README.md | 27 ++ src/titiler/multidim/cmap_data/naqfc_o3.npy | Bin 0 -> 1152 bytes src/titiler/multidim/cmap_data/naqfc_pm25.npy | Bin 0 -> 1152 bytes .../cmap_data/worldview_rainbow_4.npy | Bin 0 -> 1152 bytes src/titiler/multidim/main.py | 13 +- tests/test_cmaps.py | 61 ++++ 10 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 scripts/cmap_sources/rainbow_4.json create mode 100644 scripts/make_cmaps.py create mode 100644 src/titiler/multidim/cmap_data/README.md create mode 100644 src/titiler/multidim/cmap_data/naqfc_o3.npy create mode 100644 src/titiler/multidim/cmap_data/naqfc_pm25.npy create mode 100644 src/titiler/multidim/cmap_data/worldview_rainbow_4.npy create mode 100644 tests/test_cmaps.py diff --git a/README.md b/README.md index e507441..ebac097 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,23 @@ curl -G "$API_URL/tiles/WebMercatorQuad/4/3/6.png" \ `tilejson.json` forwards `where` into its tile template, so filtered TileJSON URLs work in map clients unchanged. +## Custom colormaps + +Besides rio-tiler's built-in colormaps, this service registers the `.npy` +lookup tables in `src/titiler/multidim/cmap_data/`: `worldview_rainbow_4` +(NASA Worldview's "Rainbow 4" palette, verbatim), `naqfc_o3` and `naqfc_pm25` +(NOAA air-quality ramps). A lookup table is applied after `rescale`, so each +one expects a specific range; the table in +[`cmap_data/README.md`](src/titiler/multidim/cmap_data/README.md) lists them. +The files are generated by `scripts/make_cmaps.py`, and a test fails if a +committed file drifts from the script. + +```bash +curl -G "$API_URL/tiles/WebMercatorQuad/4/3/6.png" \ + --data-urlencode "url=..." --data-urlencode "variable=vertical_column" \ + --data-urlencode "rescale=0,2e16" --data-urlencode "colormap_name=worldview_rainbow_4" +``` + ## Authorizing icechunk virtual chunk access Icechunk datasets can reference "virtual chunks" stored outside the repository (for example NetCDF files in another bucket). Access to those locations is denied unless each container URL prefix is explicitly authorized via the `TITILER_MULTIDIM_AUTHORIZED_CHUNK_ACCESS` setting, a JSON object mapping prefixes to access options: diff --git a/pyproject.toml b/pyproject.toml index dd6dbb4..aa9697e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,6 +130,7 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "tests/test_app.py" = ["D1"] +"tests/test_cmaps.py" = ["D1"] "tests/test_chunk_access.py" = ["D1"] "tests/test_settings.py" = ["D1"] "tests/test_earthdata.py" = ["D1"] diff --git a/scripts/cmap_sources/rainbow_4.json b/scripts/cmap_sources/rainbow_4.json new file mode 100644 index 0000000..02326b2 --- /dev/null +++ b/scripts/cmap_sources/rainbow_4.json @@ -0,0 +1,263 @@ +{ + "rainbow_4": { + "id": "rainbow_4", + "name": "Rainbow 4", + "colors": [ + "3b76c6ff", + "3a78c6ff", + "3979c6ff", + "377cc6ff", + "367ec6ff", + "3680c6ff", + "3584c6ff", + "3587c5ff", + "368ac5ff", + "368dc4ff", + "3890c3ff", + "3a94c2ff", + "3c97c1ff", + "3f9bbfff", + "429fbeff", + "45a2bcff", + "49a6bbff", + "4ca9b9ff", + "51acb7ff", + "54afb6ff", + "58b2b5ff", + "5db5b3ff", + "60b8b2ff", + "65bbb0ff", + "69bdafff", + "6dc0afff", + "73c2aeff", + "77c4adff", + "7cc7adff", + "80c9acff", + "85caacff", + "8accacff", + "8eceacff", + "94d0acff", + "98d2acff", + "9dd3abff", + "a2d5abff", + "a6d6aaff", + "acd8a9ff", + "b0daa9ff", + "b4dba8ff", + "b9dda7ff", + "bddea6ff", + "c2e0a4ff", + "c5e1a3ff", + "c9e2a2ff", + "cde4a1ff", + "d1e6a1ff", + "d5e7a0ff", + "d8e9a0ff", + "dbeaa0ff", + "dfeca0ff", + "e2eda0ff", + "e5efa1ff", + "e7f0a3ff", + "eaf1a4ff", + "ecf3a6ff", + "eef4a8ff", + "f1f5abff", + "f2f6adff", + "f4f7afff", + "f6f8b2ff", + "f7f9b4ff", + "f8f9b7ff", + "f9fab9ff", + "fafabaff", + "fbfabcff", + "fcfabdff", + "fdf9beff", + "fdf9beff", + "fef8bdff", + "fef7bcff", + "fef6bbff", + "fff5b9ff", + "fff3b7ff", + "fff2b5ff", + "fff0b1ff", + "ffefafff", + "ffedabff", + "feeba7ff", + "fee9a4ff", + "fee79fff", + "fee59cff", + "fee397ff", + "fee093ff", + "fede90ff", + "fedc8cff", + "fed988ff", + "fed784ff", + "fed481ff", + "fed27fff", + "fecf7bff", + "fecd79ff", + "feca76ff", + "fec773ff", + "fec571ff", + "fec16eff", + "febf6cff", + "febb69ff", + "feb867ff", + "feb564ff", + "feb262ff", + "feaf60ff", + "feab5dff", + "fea75bff", + "fea459ff", + "fea056ff", + "fe9d55ff", + "fe9953ff", + "fe9552ff", + "fe9251ff", + "fd8e50ff", + "fd8b50ff", + "fd8750ff", + "fc8450ff", + "fc8151ff", + "fb7e52ff", + "fa7b53ff", + "f97754ff", + "f87556ff", + "f67257ff", + "f56f59ff", + "f36d5aff", + "f26a5cff", + "f0685dff", + "ef655eff", + "ed6360ff", + "ec6061ff", + "ea5e62ff", + "e85b63ff", + "e75964ff", + "e55665ff", + "e35466ff", + "e15266ff", + "df4f67ff", + "de4d68ff", + "db4b69ff", + "d94a6aff", + "d8486bff", + "d5466cff", + "d3456cff", + "d1436eff", + "cf426eff", + "cd416fff", + "ca4070ff", + "c83f71ff", + "c53d72ff", + "c33c73ff", + "c13b74ff", + "be3a75ff", + "bc3976ff", + "b93877ff", + "b63778ff", + "b43679ff", + "b13579ff", + "af347aff", + "ac337bff", + "a9327bff", + "a7317cff", + "a3307cff", + "a12f7dff", + "9e2e7dff", + "9b2d7dff", + "992c7eff", + "952b7eff", + "932a7eff", + "90297fff", + "8d287fff", + "8a277fff", + "872680ff", + "852580ff", + "822480ff", + "802480ff", + "7d2380ff", + "7a2280ff", + "782180ff", + "762080ff", + "731f7fff", + "711e7fff", + "6f1d7fff", + "6d1c7fff", + "6a1b7eff", + "681b7eff", + "661a7eff", + "63197dff", + "61187dff", + "5f177dff", + "5d167cff", + "5b157cff", + "58157bff", + "56147aff", + "541379ff", + "521378ff", + "501277ff", + "4d1276ff", + "4b1274ff", + "491173ff", + "471171ff", + "44116fff", + "42116dff", + "40116bff", + "3d1169ff", + "3b1167ff", + "391165ff", + "371062ff", + "351060ff", + "32105eff", + "30105cff", + "2e105aff", + "2c1057ff", + "2a1055ff", + "281053ff", + "261051ff", + "25104fff", + "23104dff", + "22104bff", + "200f49ff", + "1f0f48ff", + "1e0f46ff", + "1d0f44ff", + "1c0e43ff", + "1b0e41ff", + "1a0e3fff", + "1a0d3eff", + "190d3cff", + "180d3aff", + "180d39ff", + "170c37ff", + "170c36ff", + "160c34ff", + "150b32ff", + "150b31ff", + "140b2fff", + "130a2eff", + "130a2cff", + "120a2aff", + "110928ff", + "110927ff", + "100925ff", + "100924ff", + "0f0822ff", + "0e081fff", + "0d071dff", + "0d071cff", + "0c071aff", + "0b0618ff", + "0b0617ff", + "0a0616ff", + "0a0614ff", + "090513ff", + "080511ff", + "080510ff", + "08040fff", + "07040eff", + "07040dff" + ] + } +} diff --git a/scripts/make_cmaps.py b/scripts/make_cmaps.py new file mode 100644 index 0000000..34d0e05 --- /dev/null +++ b/scripts/make_cmaps.py @@ -0,0 +1,91 @@ +"""Generate the custom colormaps shipped in ``src/titiler/multidim/cmap_data``. + +Run from the repository root:: + + python scripts/make_cmaps.py + +``tests/test_cmaps.py`` re-runs the generators and fails if a committed ``.npy`` +differs, so edit the stops here, re-run, and commit both. +See ``src/titiler/multidim/cmap_data/README.md`` for sources and the ``rescale`` +each colormap expects. +""" + +import json +import pathlib + +import numpy as np + +HERE = pathlib.Path(__file__).parent +OUT = HERE.parent / "src" / "titiler" / "multidim" / "cmap_data" + + +def _rgba(hex_color: str) -> list[int]: + h = hex_color.lstrip("#") + return [int(h[i : i + 2], 16) for i in (0, 2, 4)] + [255] + + +def _lut(values: np.ndarray, stop_values, stop_colors) -> np.ndarray: + """256x4 uint8 table: linear interpolation of ``stop_colors`` at ``values``.""" + rgba = np.array([_rgba(c) for c in stop_colors], dtype=float) + lut = np.stack( + [np.interp(values, stop_values, rgba[:, k]) for k in range(4)], axis=1 + ) + return np.rint(lut).astype(np.uint8) + + +def worldview_rainbow_4() -> np.ndarray: + """NASA Worldview custom palette "Rainbow 4", verbatim. + + 255 colors from nasa-gibs/worldview; the top color is repeated to fill 256 rows. + """ + source = json.loads((HERE / "cmap_sources" / "rainbow_4.json").read_text()) + colors = source["rainbow_4"]["colors"] # "rrggbbaa" + lut = np.array( + [[int(c[i : i + 2], 16) for i in (0, 2, 4, 6)] for c in colors], dtype=np.uint8 + ) + return np.vstack([lut, lut[-1]]) + + +# NOAA Air Quality Forecast Guidance Viewer ozone ramp, one color per 10 ppb from +# 0 to 200 ppb, as sampled from the viewer for NASA-IMPACT/veda-backend#665. +O3_STOPS_PPB = np.arange(0, 201, 10) +O3_STOP_COLORS = [ + "#AFFEB0", "#7EFE7E", "#4BFF4C", "#08F001", "#03C300", "#008700", # 0-50 + "#E1E103", "#FEBC2E", "#E17411", "#FE7879", "#BE0001", # 60-100 + "#F0B4E7", "#DC8CD3", "#C864BF", "#B43DAB", "#A02497", # 110-150 + "#8B1D83", "#781470", "#640D5B", "#500347", "#3C003C", # 160-200 +] # fmt: skip +O3_MAX_PPB = 200.0 + +# EPA AQI category upper bounds: 24-hour PM2.5 (ug/m3) paired with 8-hour ozone (ppb). +# PM2.5 borrows the ozone ramp, stretched so each PM2.5 category spans the same +# colors as its ozone category. +PM25_BOUNDS = [0.0, 9.0, 35.4, 55.4, 125.4, 225.4] +O3_BOUNDS = [0.0, 54.0, 70.0, 85.0, 105.0, 200.0] +PM25_MAX = PM25_BOUNDS[-1] + + +def naqfc_o3() -> np.ndarray: + """NAQFC ozone ramp over 0-200 ppb (``rescale=0,200`` for ppb, ``0,0.2`` for ppm).""" + return _lut(np.linspace(0, O3_MAX_PPB, 256), O3_STOPS_PPB, O3_STOP_COLORS) + + +def naqfc_pm25() -> np.ndarray: + """NAQFC PM2.5 ramp over 0-225.4 ug/m3 (``rescale=0,225.4``).""" + pm25 = np.linspace(0, PM25_MAX, 256) + o3_equivalent = np.interp(pm25, PM25_BOUNDS, O3_BOUNDS) + return _lut(o3_equivalent, O3_STOPS_PPB, O3_STOP_COLORS) + + +CMAPS = { + "worldview_rainbow_4": worldview_rainbow_4, + "naqfc_o3": naqfc_o3, + "naqfc_pm25": naqfc_pm25, +} + +if __name__ == "__main__": + for name, build in CMAPS.items(): + lut = build() + assert lut.shape == (256, 4) and lut.dtype == np.uint8, name + np.save(OUT / f"{name}.npy", lut) + print(f"wrote {OUT / name}.npy") diff --git a/src/titiler/multidim/cmap_data/README.md b/src/titiler/multidim/cmap_data/README.md new file mode 100644 index 0000000..2f675b9 --- /dev/null +++ b/src/titiler/multidim/cmap_data/README.md @@ -0,0 +1,27 @@ +# Custom colormaps + +Every `.npy` file in this directory is registered at startup under its file name and +can be requested with `colormap_name=`. Each is a 256-row RGBA lookup table that +titiler applies **after** `rescale` stretches the data to 0-255, so each one is only +meaningful with the `rescale` listed below. + +The files are generated by `scripts/make_cmaps.py`; `tests/test_cmaps.py` fails if a +committed file no longer matches the script. Do not edit the `.npy` files by hand. + +| `colormap_name` | Source | Use with | +|---|---|---| +| `worldview_rainbow_4` | NASA Worldview custom palette "Rainbow 4", copied verbatim (255 colors) from [nasa-gibs/worldview](https://github.com/nasa-gibs/worldview/blob/main/config/default/common/config/palettes-custom.json/rainbow_4.json), kept in `scripts/cmap_sources/` | the value range you would set in Worldview, e.g. `rescale=0,2e16` for TEMPO NO2 tropospheric column in molecules/cm² | +| `naqfc_o3` | NOAA Air Quality Forecast Guidance Viewer ozone ramp, one color per 10 ppb, sampled from the viewer for [veda-backend#665](https://github.com/NASA-IMPACT/veda-backend/pull/665) | `rescale=0,200` for data in ppb, `rescale=0,0.2` for ppm | +| `naqfc_pm25` | the ozone ramp above, stretched so each EPA 24-hour PM2.5 AQI category (bounds 9.0, 35.4, 55.4, 125.4, 225.4 µg/m³) spans the same colors as its 8-hour ozone category (54, 70, 85, 105, 200 ppb) | `rescale=0,225.4` for data in µg/m³ | + +Known limits: + +- The NAQFC ramps are sampled from a screenshot, not from a provider color table. The + provider's stated intent (Air-Quality-Portal/aq-arch#192) is category boundaries at + the EPA breakpoints with a gradient inside each category; the ozone ramp as sampled + does not change color exactly at those breakpoints. Replace the stops in + `scripts/make_cmaps.py` when the provider supplies the table. +- A lookup table places category boundaries to the nearest 1/255 of the `rescale` + range: 0.8 ppb for ozone, 0.9 µg/m³ for PM2.5. +- Values above the `rescale` maximum take the top color; values below the minimum take + the bottom color. diff --git a/src/titiler/multidim/cmap_data/naqfc_o3.npy b/src/titiler/multidim/cmap_data/naqfc_o3.npy new file mode 100644 index 0000000000000000000000000000000000000000..9af70f3cc97976d7778e0ec1731fc85a627736b4 GIT binary patch literal 1152 zcmbWu`#07H0LSs~_mN9ltP9bs9x=CBXIZu7Adyr`ILTNh@mN-bhX=)^#u$}QQi@b# z(cx$|)5$blEKQs)x~rHXr6%kCeeb(}VV`qeKYTvtdg;$?ZNo^#2Miyu;Ax3DL%XIjm}ka7&SV~Bn9!GIOwWc1+>IYcPrNC8 z3FbUVuw@{@fkBNULmD@pY8Eq`=*LK65HAwLc$K)8H%V&VY7%*|E{fr>a2^K+(eL9+ zujfL#on5#-a~7BFKBU8Z8f_+~oG~!uq_z&ty84_lGNaXEI_GD&(&6gICGT*$R%*EM zbr#)8d+5om=6+!l56e3ks=dchT_2BXdKs+hVW9FZ{fF;xzx+0TmENScM1wbZT0;pgu!;%xTU?wa^y%JAXtp z(;D_0XI7oyRPb?Hf*Ls)&_MI@Uug2J=Y;o99QUf@*M+q-c+~K-+fnN0R&&hh2z8D> zQu|p2HM4)<=*&Y@Ih1qc;{#MqFQvkMKZor~IApVza;sv>EQ=_$+{HeNLiSE8V9&H2 z>^9G%$b1_+mAMosbI6~XP2SWjwoS=o>y!*~-rvIaW}C?}+r)S8rI9f?m2XW`*lfCi zbdzM#CMELCBn>IX@oY3wlWY`A(!>}v6QYTqunzV3wZx8(Bxc-KM30Lg>fLbG8m=bN zFqAb0Aw(DiqZ+%Cu(3g`ekYJsV*&`#UqSHb<$N*Pk03o?KG$7FfQ~o*I!jot?S$qVQm`Vm^x$?szKP@DS%QUv$MyoXb4fT)_nw!5JsPiGKm++k__o literal 0 HcmV?d00001 diff --git a/src/titiler/multidim/cmap_data/naqfc_pm25.npy b/src/titiler/multidim/cmap_data/naqfc_pm25.npy new file mode 100644 index 0000000000000000000000000000000000000000..8e0eaca5eed18083cd877ede8f27d2fd91638a35 GIT binary patch literal 1152 zcmbWr`&-Qi0LSrjX|?3iMbV-Tm7_U$G|XJODA80}Rx47{Wzo`Eb$P=x?Sj>y1$y0LbZSIM3Pp(1MYv{*3Ck0sEEJP;Rzm7|2?t9hWK~Ew zRVAUQRzg{Wl$s_f&3B}$fZCe@VKq+$KL!PVo(XHU*700e&yWzx3n5%9l9xghuQbtGN`^IYyw<1~ z(P((B78s6;=cV#E^Jr}#ef|OT`ufr1vzqQz-gJ3* z(&_#o9d65McX6f7$(hzA4zw&>#O?PN&@^`*jdSd2m^GW5GZfTMx1-K>8Z}d`skV}F z-C`2gCYp1_)Qn1F6E2T4qO#ssI-~Q6}x#{o4JT8dnc~XccXfd2Q`j9)Vlgpw<3so&rojqY^1?2hQ>gF=BPB< z5{`0jS3cdDW%T`Yi~fQx9_DxQ;B*JQIqlp((MHz~t^9keg?mSC(|+h5?xr_#=bHvv z_THfR>w20}YiUgRn_FL2b8|}-^$Az06)LHTub^64PF3_Ju5BviN<;}2p~YNYf1a|y zzbFkj%SFE;F8KVx`BjCS^C;j?_g^VmmdBZ;x%}>Uih{*I^V`B4@)w*Wcm9u@o_m~M z?6b+4oyEzS-*IBbQI6XkCR=`p?`<w_GzKEStA_meqg9~o2ja!|IL1F|&s zTkT?>e>H2~{)hlQZ$8oW!dKUWkKbB}kB&Ru(&czb-SCtyW2M9ucZmxtBujDAauqw{ UB6h-A?1-b-0SD0%7K;}19|XRQ^Z)<= literal 0 HcmV?d00001 diff --git a/src/titiler/multidim/cmap_data/worldview_rainbow_4.npy b/src/titiler/multidim/cmap_data/worldview_rainbow_4.npy new file mode 100644 index 0000000000000000000000000000000000000000..c7a1052d9d3fcbb6b4d73ee3d672476d346a2386 GIT binary patch literal 1152 zcmbWr`&Z9*0LO7V$0c1q-x5jq4*ABKOH(Nsa+%heN<}E8zA3t>Z@PYOoSb%SoX+By zW2Vw6t#A9%3aw(IOlQq78?(8tHI2DMH@u(w%%8B=IgcM+&*BfmgMuU97?L$4Ulp&` z#AT>_l`8Gl*(!IXDj_W+Gb1*2eOgAm`oF#`Hd&*VyCyMqi(39}9-dzA$~n{Al{)4B zoaMZ%Hhgp1@X2k%Tib@0US@2=vp`nZiq}rru5-*SI*ad~7X0>|VZnjZ_#ZmO(y}H3 zDjNBqs(}#G3Bt_BSzTLCRQ=b)oUBFNc$B1OGb!Ji(X@O;_PH9g=W8%tFtMZE#Ln+c z?Eb;To*zwocG<+iE7g=;t)}AVFEL%K;^?nc)ZI8j!|#jhJb~&wg4s+pd86AI= za_P@fu6C7h?S2V2x=XnApoF^*OXz%5O4sAVbU!KQK~Dw$JUhaZ=hZyzt>Iapncn_d z`UdLgA2`9lU;~4L-|%9viI;=Tyc#(5n&@vv^fe)R8{v5a^qhdF_3-!@JTk+>YQ*1_ zi2LP;u0x2<&k^_bA#N8VZWbYa`vh_QBg8KSh@ZA0E*lUR^AR1ni1sYRc@3g99dRZV zaXJ~%n1ndF5m6tHsNH}t#~`Ys5S42Y<&lVz2*kl~#Qv3ty`hLtLwL12m{%Xm3WIrB zAlnwg3w{orO+HyP%+2PwcMf%4xg7J%#XKhu(>wW8dE`?$Tg%~DT1sc? z_+o~R18?it@2+R>TY8G8>nWOMV3(VLohk!`Q;qC!HL`t*5u;2u*+||bBROvx$x<58 zOf--_!9d!011T;BHjUGhC`)kG6E{{ztdoxQV{}A~*0R=7%WB7bB1YvA=8#LMLk_|A z*#y~V5on)DfI`E2igcDJ((qTLuu!p?`HCdwDK_GxP~$Caz*CCBLyBgWw2m3l8r-E- zOqarOlU6cS3c*!c!DK0jNm3w6y8tHIEoXw=GRE62!Nqnl<7^k)<7F;b kk8s9nxRY!QmcvKOMlo`j0~W&+G6{>Jc8nNmE3 pm25[10][0] and o3[69][1] > o3[69][0] + assert not np.array_equal(pm25, o3) + + +def test_api_lists_and_serves_custom_colormaps(app): + listed = {c["id"] for c in app.get("/colorMaps").json()["colormaps"]} + for name in make_cmaps.CMAPS: + assert name in listed + assert app.get(f"/colorMaps/{name}").status_code == 200 + + +def test_tile_renders_with_custom_colormap(app): + params = { + **store_params["zarr_store_v2"]["params"], + "rescale": "0,1", + "colormap_name": "naqfc_o3", + } + response = app.get("/tiles/WebMercatorQuad/0/0/0.png", params=params) + assert response.status_code == 200 + assert response.headers["content-type"] == "image/png" From 9ba7e7915cc2068119cf04daf20fa8b4b115a379 Mon Sep 17 00:00:00 2001 From: Max Jones <14077947+maxrjones@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:15:06 +0000 Subject: [PATCH 2/8] refactor: build custom colormaps at import instead of from .npy files Per review on #200: the three colormaps are now built when the package is imported (`titiler/multidim/colormaps.py`), so there are no generated files to drift from their source. This removes `scripts/make_cmaps.py`, the `cmap_data/` .npy files, and the drift test. The generated tables are byte-identical to the previous .npy files. Also from the review: - the dict passed to `cmap.register` is the only list of colormaps (previously a script dict plus a directory glob in main.py) - ramp stops and AQI breakpoints are dicts, so pairs can't misalign - rio-tiler's `parse_color` replaces the hand-written hex parsers - `rainbow_4.json` lives in the package and is read via importlib.resources - the Worldview source link is pinned to commit 7d16ee5 - the colormap table and known limits move into the main README - tests check every rainbow color, catch out-of-order interpolation points, and are parametrized per colormap Co-Authored-By: Claude Opus 5.5 --- README.md | 30 ++-- scripts/make_cmaps.py | 91 ------------- src/titiler/multidim/cmap_data/README.md | 27 ---- src/titiler/multidim/cmap_data/naqfc_o3.npy | Bin 1152 -> 0 bytes src/titiler/multidim/cmap_data/naqfc_pm25.npy | Bin 1152 -> 0 bytes .../cmap_data/worldview_rainbow_4.npy | Bin 1152 -> 0 bytes src/titiler/multidim/colormaps.py | 128 ++++++++++++++++++ src/titiler/multidim/main.py | 8 +- .../titiler/multidim}/rainbow_4.json | 0 tests/test_cmaps.py | 78 +++++------ 10 files changed, 192 insertions(+), 170 deletions(-) delete mode 100644 scripts/make_cmaps.py delete mode 100644 src/titiler/multidim/cmap_data/README.md delete mode 100644 src/titiler/multidim/cmap_data/naqfc_o3.npy delete mode 100644 src/titiler/multidim/cmap_data/naqfc_pm25.npy delete mode 100644 src/titiler/multidim/cmap_data/worldview_rainbow_4.npy create mode 100644 src/titiler/multidim/colormaps.py rename {scripts/cmap_sources => src/titiler/multidim}/rainbow_4.json (100%) diff --git a/README.md b/README.md index ebac097..b31fe15 100644 --- a/README.md +++ b/README.md @@ -77,14 +77,28 @@ TileJSON URLs work in map clients unchanged. ## Custom colormaps -Besides rio-tiler's built-in colormaps, this service registers the `.npy` -lookup tables in `src/titiler/multidim/cmap_data/`: `worldview_rainbow_4` -(NASA Worldview's "Rainbow 4" palette, verbatim), `naqfc_o3` and `naqfc_pm25` -(NOAA air-quality ramps). A lookup table is applied after `rescale`, so each -one expects a specific range; the table in -[`cmap_data/README.md`](src/titiler/multidim/cmap_data/README.md) lists them. -The files are generated by `scripts/make_cmaps.py`, and a test fails if a -committed file drifts from the script. +Besides rio-tiler's built-in colormaps, this service registers the colormaps +built in `src/titiler/multidim/colormaps.py`. Each is a 256-row table that +titiler applies **after** `rescale` stretches the data to 0-255, so each one is +only meaningful with the `rescale` listed below. + +| `colormap_name` | Source | Use with | +|---|---|---| +| `worldview_rainbow_4` | NASA Worldview custom palette "Rainbow 4", copied verbatim (255 colors) from [nasa-gibs/worldview](https://github.com/nasa-gibs/worldview/blob/7d16ee53eb38a90fe66c9860015b51094c96ed25/config/default/common/config/palettes-custom.json/rainbow_4.json) into `src/titiler/multidim/rainbow_4.json` | the value range you would set in Worldview, e.g. `rescale=0,2e16` for TEMPO NO2 tropospheric column in molecules/cm² | +| `naqfc_o3` | NOAA Air Quality Forecast Guidance Viewer ozone ramp, one color per 10 ppb, sampled from the viewer for [veda-backend#665](https://github.com/NASA-IMPACT/veda-backend/pull/665) | `rescale=0,200` for data in ppb, `rescale=0,0.2` for ppm | +| `naqfc_pm25` | the ozone ramp above, stretched so each EPA 24-hour PM2.5 AQI category (bounds 9.0, 35.4, 55.4, 125.4, 225.4 µg/m³) spans the same colors as its 8-hour ozone category (54, 70, 85, 105, 200 ppb) | `rescale=0,225.4` for data in µg/m³ | + +Known limits: + +- The NAQFC ramps are sampled from a screenshot, not from a provider color table. The + provider's stated intent (Air-Quality-Portal/aq-arch#192) is category boundaries at + the EPA breakpoints with a gradient inside each category; the ozone ramp as sampled + does not change color exactly at those breakpoints. Replace the stops in + `colormaps.py` when the provider supplies the table. +- A lookup table places category boundaries to the nearest 1/255 of the `rescale` + range: 0.8 ppb for ozone, 0.9 µg/m³ for PM2.5. +- Values above the `rescale` maximum take the top color; values below the minimum take + the bottom color. ```bash curl -G "$API_URL/tiles/WebMercatorQuad/4/3/6.png" \ diff --git a/scripts/make_cmaps.py b/scripts/make_cmaps.py deleted file mode 100644 index 34d0e05..0000000 --- a/scripts/make_cmaps.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Generate the custom colormaps shipped in ``src/titiler/multidim/cmap_data``. - -Run from the repository root:: - - python scripts/make_cmaps.py - -``tests/test_cmaps.py`` re-runs the generators and fails if a committed ``.npy`` -differs, so edit the stops here, re-run, and commit both. -See ``src/titiler/multidim/cmap_data/README.md`` for sources and the ``rescale`` -each colormap expects. -""" - -import json -import pathlib - -import numpy as np - -HERE = pathlib.Path(__file__).parent -OUT = HERE.parent / "src" / "titiler" / "multidim" / "cmap_data" - - -def _rgba(hex_color: str) -> list[int]: - h = hex_color.lstrip("#") - return [int(h[i : i + 2], 16) for i in (0, 2, 4)] + [255] - - -def _lut(values: np.ndarray, stop_values, stop_colors) -> np.ndarray: - """256x4 uint8 table: linear interpolation of ``stop_colors`` at ``values``.""" - rgba = np.array([_rgba(c) for c in stop_colors], dtype=float) - lut = np.stack( - [np.interp(values, stop_values, rgba[:, k]) for k in range(4)], axis=1 - ) - return np.rint(lut).astype(np.uint8) - - -def worldview_rainbow_4() -> np.ndarray: - """NASA Worldview custom palette "Rainbow 4", verbatim. - - 255 colors from nasa-gibs/worldview; the top color is repeated to fill 256 rows. - """ - source = json.loads((HERE / "cmap_sources" / "rainbow_4.json").read_text()) - colors = source["rainbow_4"]["colors"] # "rrggbbaa" - lut = np.array( - [[int(c[i : i + 2], 16) for i in (0, 2, 4, 6)] for c in colors], dtype=np.uint8 - ) - return np.vstack([lut, lut[-1]]) - - -# NOAA Air Quality Forecast Guidance Viewer ozone ramp, one color per 10 ppb from -# 0 to 200 ppb, as sampled from the viewer for NASA-IMPACT/veda-backend#665. -O3_STOPS_PPB = np.arange(0, 201, 10) -O3_STOP_COLORS = [ - "#AFFEB0", "#7EFE7E", "#4BFF4C", "#08F001", "#03C300", "#008700", # 0-50 - "#E1E103", "#FEBC2E", "#E17411", "#FE7879", "#BE0001", # 60-100 - "#F0B4E7", "#DC8CD3", "#C864BF", "#B43DAB", "#A02497", # 110-150 - "#8B1D83", "#781470", "#640D5B", "#500347", "#3C003C", # 160-200 -] # fmt: skip -O3_MAX_PPB = 200.0 - -# EPA AQI category upper bounds: 24-hour PM2.5 (ug/m3) paired with 8-hour ozone (ppb). -# PM2.5 borrows the ozone ramp, stretched so each PM2.5 category spans the same -# colors as its ozone category. -PM25_BOUNDS = [0.0, 9.0, 35.4, 55.4, 125.4, 225.4] -O3_BOUNDS = [0.0, 54.0, 70.0, 85.0, 105.0, 200.0] -PM25_MAX = PM25_BOUNDS[-1] - - -def naqfc_o3() -> np.ndarray: - """NAQFC ozone ramp over 0-200 ppb (``rescale=0,200`` for ppb, ``0,0.2`` for ppm).""" - return _lut(np.linspace(0, O3_MAX_PPB, 256), O3_STOPS_PPB, O3_STOP_COLORS) - - -def naqfc_pm25() -> np.ndarray: - """NAQFC PM2.5 ramp over 0-225.4 ug/m3 (``rescale=0,225.4``).""" - pm25 = np.linspace(0, PM25_MAX, 256) - o3_equivalent = np.interp(pm25, PM25_BOUNDS, O3_BOUNDS) - return _lut(o3_equivalent, O3_STOPS_PPB, O3_STOP_COLORS) - - -CMAPS = { - "worldview_rainbow_4": worldview_rainbow_4, - "naqfc_o3": naqfc_o3, - "naqfc_pm25": naqfc_pm25, -} - -if __name__ == "__main__": - for name, build in CMAPS.items(): - lut = build() - assert lut.shape == (256, 4) and lut.dtype == np.uint8, name - np.save(OUT / f"{name}.npy", lut) - print(f"wrote {OUT / name}.npy") diff --git a/src/titiler/multidim/cmap_data/README.md b/src/titiler/multidim/cmap_data/README.md deleted file mode 100644 index 2f675b9..0000000 --- a/src/titiler/multidim/cmap_data/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Custom colormaps - -Every `.npy` file in this directory is registered at startup under its file name and -can be requested with `colormap_name=`. Each is a 256-row RGBA lookup table that -titiler applies **after** `rescale` stretches the data to 0-255, so each one is only -meaningful with the `rescale` listed below. - -The files are generated by `scripts/make_cmaps.py`; `tests/test_cmaps.py` fails if a -committed file no longer matches the script. Do not edit the `.npy` files by hand. - -| `colormap_name` | Source | Use with | -|---|---|---| -| `worldview_rainbow_4` | NASA Worldview custom palette "Rainbow 4", copied verbatim (255 colors) from [nasa-gibs/worldview](https://github.com/nasa-gibs/worldview/blob/main/config/default/common/config/palettes-custom.json/rainbow_4.json), kept in `scripts/cmap_sources/` | the value range you would set in Worldview, e.g. `rescale=0,2e16` for TEMPO NO2 tropospheric column in molecules/cm² | -| `naqfc_o3` | NOAA Air Quality Forecast Guidance Viewer ozone ramp, one color per 10 ppb, sampled from the viewer for [veda-backend#665](https://github.com/NASA-IMPACT/veda-backend/pull/665) | `rescale=0,200` for data in ppb, `rescale=0,0.2` for ppm | -| `naqfc_pm25` | the ozone ramp above, stretched so each EPA 24-hour PM2.5 AQI category (bounds 9.0, 35.4, 55.4, 125.4, 225.4 µg/m³) spans the same colors as its 8-hour ozone category (54, 70, 85, 105, 200 ppb) | `rescale=0,225.4` for data in µg/m³ | - -Known limits: - -- The NAQFC ramps are sampled from a screenshot, not from a provider color table. The - provider's stated intent (Air-Quality-Portal/aq-arch#192) is category boundaries at - the EPA breakpoints with a gradient inside each category; the ozone ramp as sampled - does not change color exactly at those breakpoints. Replace the stops in - `scripts/make_cmaps.py` when the provider supplies the table. -- A lookup table places category boundaries to the nearest 1/255 of the `rescale` - range: 0.8 ppb for ozone, 0.9 µg/m³ for PM2.5. -- Values above the `rescale` maximum take the top color; values below the minimum take - the bottom color. diff --git a/src/titiler/multidim/cmap_data/naqfc_o3.npy b/src/titiler/multidim/cmap_data/naqfc_o3.npy deleted file mode 100644 index 9af70f3cc97976d7778e0ec1731fc85a627736b4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1152 zcmbWu`#07H0LSs~_mN9ltP9bs9x=CBXIZu7Adyr`ILTNh@mN-bhX=)^#u$}QQi@b# z(cx$|)5$blEKQs)x~rHXr6%kCeeb(}VV`qeKYTvtdg;$?ZNo^#2Miyu;Ax3DL%XIjm}ka7&SV~Bn9!GIOwWc1+>IYcPrNC8 z3FbUVuw@{@fkBNULmD@pY8Eq`=*LK65HAwLc$K)8H%V&VY7%*|E{fr>a2^K+(eL9+ zujfL#on5#-a~7BFKBU8Z8f_+~oG~!uq_z&ty84_lGNaXEI_GD&(&6gICGT*$R%*EM zbr#)8d+5om=6+!l56e3ks=dchT_2BXdKs+hVW9FZ{fF;xzx+0TmENScM1wbZT0;pgu!;%xTU?wa^y%JAXtp z(;D_0XI7oyRPb?Hf*Ls)&_MI@Uug2J=Y;o99QUf@*M+q-c+~K-+fnN0R&&hh2z8D> zQu|p2HM4)<=*&Y@Ih1qc;{#MqFQvkMKZor~IApVza;sv>EQ=_$+{HeNLiSE8V9&H2 z>^9G%$b1_+mAMosbI6~XP2SWjwoS=o>y!*~-rvIaW}C?}+r)S8rI9f?m2XW`*lfCi zbdzM#CMELCBn>IX@oY3wlWY`A(!>}v6QYTqunzV3wZx8(Bxc-KM30Lg>fLbG8m=bN zFqAb0Aw(DiqZ+%Cu(3g`ekYJsV*&`#UqSHb<$N*Pk03o?KG$7FfQ~o*I!jot?S$qVQm`Vm^x$?szKP@DS%QUv$MyoXb4fT)_nw!5JsPiGKm++k__o diff --git a/src/titiler/multidim/cmap_data/naqfc_pm25.npy b/src/titiler/multidim/cmap_data/naqfc_pm25.npy deleted file mode 100644 index 8e0eaca5eed18083cd877ede8f27d2fd91638a35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1152 zcmbWr`&-Qi0LSrjX|?3iMbV-Tm7_U$G|XJODA80}Rx47{Wzo`Eb$P=x?Sj>y1$y0LbZSIM3Pp(1MYv{*3Ck0sEEJP;Rzm7|2?t9hWK~Ew zRVAUQRzg{Wl$s_f&3B}$fZCe@VKq+$KL!PVo(XHU*700e&yWzx3n5%9l9xghuQbtGN`^IYyw<1~ z(P((B78s6;=cV#E^Jr}#ef|OT`ufr1vzqQz-gJ3* z(&_#o9d65McX6f7$(hzA4zw&>#O?PN&@^`*jdSd2m^GW5GZfTMx1-K>8Z}d`skV}F z-C`2gCYp1_)Qn1F6E2T4qO#ssI-~Q6}x#{o4JT8dnc~XccXfd2Q`j9)Vlgpw<3so&rojqY^1?2hQ>gF=BPB< z5{`0jS3cdDW%T`Yi~fQx9_DxQ;B*JQIqlp((MHz~t^9keg?mSC(|+h5?xr_#=bHvv z_THfR>w20}YiUgRn_FL2b8|}-^$Az06)LHTub^64PF3_Ju5BviN<;}2p~YNYf1a|y zzbFkj%SFE;F8KVx`BjCS^C;j?_g^VmmdBZ;x%}>Uih{*I^V`B4@)w*Wcm9u@o_m~M z?6b+4oyEzS-*IBbQI6XkCR=`p?`<w_GzKEStA_meqg9~o2ja!|IL1F|&s zTkT?>e>H2~{)hlQZ$8oW!dKUWkKbB}kB&Ru(&czb-SCtyW2M9ucZmxtBujDAauqw{ UB6h-A?1-b-0SD0%7K;}19|XRQ^Z)<= diff --git a/src/titiler/multidim/cmap_data/worldview_rainbow_4.npy b/src/titiler/multidim/cmap_data/worldview_rainbow_4.npy deleted file mode 100644 index c7a1052d9d3fcbb6b4d73ee3d672476d346a2386..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1152 zcmbWr`&Z9*0LO7V$0c1q-x5jq4*ABKOH(Nsa+%heN<}E8zA3t>Z@PYOoSb%SoX+By zW2Vw6t#A9%3aw(IOlQq78?(8tHI2DMH@u(w%%8B=IgcM+&*BfmgMuU97?L$4Ulp&` z#AT>_l`8Gl*(!IXDj_W+Gb1*2eOgAm`oF#`Hd&*VyCyMqi(39}9-dzA$~n{Al{)4B zoaMZ%Hhgp1@X2k%Tib@0US@2=vp`nZiq}rru5-*SI*ad~7X0>|VZnjZ_#ZmO(y}H3 zDjNBqs(}#G3Bt_BSzTLCRQ=b)oUBFNc$B1OGb!Ji(X@O;_PH9g=W8%tFtMZE#Ln+c z?Eb;To*zwocG<+iE7g=;t)}AVFEL%K;^?nc)ZI8j!|#jhJb~&wg4s+pd86AI= za_P@fu6C7h?S2V2x=XnApoF^*OXz%5O4sAVbU!KQK~Dw$JUhaZ=hZyzt>Iapncn_d z`UdLgA2`9lU;~4L-|%9viI;=Tyc#(5n&@vv^fe)R8{v5a^qhdF_3-!@JTk+>YQ*1_ zi2LP;u0x2<&k^_bA#N8VZWbYa`vh_QBg8KSh@ZA0E*lUR^AR1ni1sYRc@3g99dRZV zaXJ~%n1ndF5m6tHsNH}t#~`Ys5S42Y<&lVz2*kl~#Qv3ty`hLtLwL12m{%Xm3WIrB zAlnwg3w{orO+HyP%+2PwcMf%4xg7J%#XKhu(>wW8dE`?$Tg%~DT1sc? z_+o~R18?it@2+R>TY8G8>nWOMV3(VLohk!`Q;qC!HL`t*5u;2u*+||bBROvx$x<58 zOf--_!9d!011T;BHjUGhC`)kG6E{{ztdoxQV{}A~*0R=7%WB7bB1YvA=8#LMLk_|A z*#y~V5on)DfI`E2igcDJ((qTLuu!p?`HCdwDK_GxP~$Caz*CCBLyBgWw2m3l8r-E- zOqarOlU6cS3c*!c!DK0jNm3w6y8tHIEoXw=GRE62!Nqnl<7^k)<7F;b kk8s9nxRY!QmcvKOMlo`j0~W&+G6{>Jc8nNmE3 GDALColorMapType: + """Turn ``ramp`` into a colormap with one row for each item in ``values``. + + ``ramp`` maps a few data values, its stops, to colors. Row ``i`` of the + result is the color at ``values[i]``, blended from the colors of the two + nearest stops when ``values[i]`` falls between them. Each colormap in this + module passes 256 values, one for each pixel value that ``rescale`` + produces. + + >>> ramp = {0: "#000000", 10: "#FFFFFF"} # black at 0, white at 10 + >>> _colormap_from_ramp(ramp, np.array([0, 5, 10])) + {0: (0, 0, 0, 255), 1: (128, 128, 128, 255), 2: (255, 255, 255, 255)} + """ + colors = np.array([parse_color(c) for c in ramp.values()]) + channels = [np.interp(values, list(ramp), channel) for channel in colors.T] + rows = np.rint(np.stack(channels, axis=1)).astype(int).tolist() + + return dict(enumerate(map(tuple, rows))) + + +def _o3_equivalent_ppb(ugm3: np.ndarray) -> np.ndarray: + """Map each PM2.5 value (ug/m3) to its ozone equivalent (ppb). + + The equivalent sits at the same relative position within the same AQI + category, so PM2.5 and ozone in the same category get the same colors. For + example, 22.2 ug/m3 is halfway through PM2.5's Moderate category (9.0 to + 35.4), so its equivalent is 62 ppb, halfway through ozone's (54 to 70). + + >>> _o3_equivalent_ppb(np.array([9.0, 22.2, 35.4])) + array([54., 62., 70.]) + """ + return np.interp( + ugm3, list(PM25_UGM3_TO_O3_PPB), list(PM25_UGM3_TO_O3_PPB.values()) + ) + + +def worldview_rainbow_4() -> GDALColorMapType: + """NASA Worldview custom palette "Rainbow 4", verbatim. + + The palette has 255 colors, and rio-tiler would leave a 255-row colormap's + top value transparent, so the top color is repeated to fill 256 rows. + """ + source = json.loads((files(__package__) / "rainbow_4.json").read_text()) + colors = [parse_color(f"#{c}") for c in source["rainbow_4"]["colors"]] + + return dict(enumerate([*colors, colors[-1]])) + + +def naqfc_o3() -> GDALColorMapType: + """NAQFC ozone ramp, from 0 ppb to its top stop.""" + ppb = np.linspace(0, max(O3_RAMP_PPB), 256) + + return _colormap_from_ramp(O3_RAMP_PPB, ppb) + + +def naqfc_pm25() -> GDALColorMapType: + """NAQFC PM2.5 ramp: the ozone ramp, stretched over the PM2.5 AQI categories.""" + ugm3 = np.linspace(0, max(PM25_UGM3_TO_O3_PPB), 256) + + return _colormap_from_ramp(O3_RAMP_PPB, _o3_equivalent_ppb(ugm3)) + + +cmap = default_cmap.register( + { + "worldview_rainbow_4": worldview_rainbow_4(), + "naqfc_o3": naqfc_o3(), + "naqfc_pm25": naqfc_pm25(), + } +) diff --git a/src/titiler/multidim/main.py b/src/titiler/multidim/main.py index 5e24d78..94cd6f7 100644 --- a/src/titiler/multidim/main.py +++ b/src/titiler/multidim/main.py @@ -2,7 +2,6 @@ import logging import os -from pathlib import Path from typing import Annotated, Literal import icechunk @@ -14,7 +13,6 @@ S3CredentialsRequestFailure, ) from fastapi import FastAPI, Query -from rio_tiler.colormap import cmap as default_cmap from starlette import status from starlette.middleware.cors import CORSMiddleware from starlette.requests import Request @@ -35,6 +33,7 @@ from titiler.xarray.extensions import DatasetMetadataExtension, ValidateExtension from titiler.multidim import __version__ as titiler_version +from titiler.multidim.colormaps import cmap from titiler.multidim.extensions import open_metadata_dataset from titiler.multidim.factory import XarrayMosaicTilerFactory from titiler.multidim.reader import WhereConditionError @@ -83,10 +82,7 @@ } ############################################################################### -# Custom colormaps, registered by file name (see cmap_data/README.md) -cmap = default_cmap.register( - {f.stem: str(f) for f in (Path(__file__).parent / "cmap_data").glob("*.npy")} -) +# Custom colormaps (see colormaps.py) ColorMapParams = create_colormap_dependency(cmap) ############################################################################### diff --git a/scripts/cmap_sources/rainbow_4.json b/src/titiler/multidim/rainbow_4.json similarity index 100% rename from scripts/cmap_sources/rainbow_4.json rename to src/titiler/multidim/rainbow_4.json diff --git a/tests/test_cmaps.py b/tests/test_cmaps.py index a50707e..701dd32 100644 --- a/tests/test_cmaps.py +++ b/tests/test_cmaps.py @@ -1,53 +1,54 @@ -"""Custom colormaps: committed files match the generator and the API serves them.""" +"""Custom colormaps: the tables keep their stated properties and the API serves them.""" -import importlib.util -import pathlib +import json +from importlib.resources import files +from itertools import pairwise -import numpy as np import pytest from test_app import store_params -REPO = pathlib.Path(__file__).resolve().parents[1] -CMAP_DIR = REPO / "src" / "titiler" / "multidim" / "cmap_data" - -_spec = importlib.util.spec_from_file_location( - "make_cmaps", REPO / "scripts" / "make_cmaps.py" -) -make_cmaps = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(make_cmaps) - - -@pytest.mark.parametrize("name", list(make_cmaps.CMAPS)) -def test_committed_file_matches_generator(name): - committed = np.load(CMAP_DIR / f"{name}.npy") - assert committed.shape == (256, 4) and committed.dtype == np.uint8 - np.testing.assert_array_equal(committed, make_cmaps.CMAPS[name]()) +from titiler.multidim import colormaps def test_rainbow_4_is_verbatim_worldview(): - lut = np.load(CMAP_DIR / "worldview_rainbow_4.npy") - assert lut[0].tolist() == [0x3B, 0x76, 0xC6, 255] # first Worldview color - assert ( - lut[254].tolist() == lut[255].tolist() == [0x07, 0x04, 0x0D, 255] - ) # last, repeated - + source = json.loads((files("titiler.multidim") / "rainbow_4.json").read_text()) + colors = [tuple(bytes.fromhex(c)) for c in source["rainbow_4"]["colors"]] + rainbow = colormaps.cmap.get("worldview_rainbow_4") + + # After rescale, pixel values run from 0 to 255, and rio-tiler leaves any + # value without a row transparent, so the colormap needs all 256 rows. + assert len(rainbow) == 256 + # Every Worldview color in order, then the top one again to fill row 255. + assert list(rainbow.values()) == [*colors, colors[-1]] + + +@pytest.mark.parametrize( + "points", + [ + list(colormaps.O3_RAMP_PPB), + list(colormaps.PM25_UGM3_TO_O3_PPB), + list(colormaps.PM25_UGM3_TO_O3_PPB.values()), + ], + ids=["ozone ramp", "PM2.5 breakpoints", "ozone breakpoints"], +) +def test_interpolation_points_increase(points): + # np.interp does not check that the points it interpolates between increase, + # so an out-of-order entry silently scrambles a colormap rather than failing. + out_of_order = [ + f"{a} is not less than the next point, {b}" + for a, b in pairwise(points) + if a >= b + ] -def test_pm25_is_the_ozone_ramp_stretched_over_aqi_categories(): - o3 = np.load(CMAP_DIR / "naqfc_o3.npy") - pm25 = np.load(CMAP_DIR / "naqfc_pm25.npy") - assert pm25[0].tolist() == o3[0].tolist() # 0 ug/m3 and 0 ppb share a color - assert pm25[255].tolist() == o3[255].tolist() # 225.4 ug/m3 and 200 ppb too - # "Good" PM2.5 ends at 9 ug/m3 (index 10); "Good" ozone ends at 54 ppb (index 69). - # Both are still green, and PM2.5 reaches it far earlier in the table. - assert pm25[10][1] > pm25[10][0] and o3[69][1] > o3[69][0] - assert not np.array_equal(pm25, o3) + assert out_of_order == [] -def test_api_lists_and_serves_custom_colormaps(app): +@pytest.mark.parametrize("name", ["worldview_rainbow_4", "naqfc_o3", "naqfc_pm25"]) +def test_api_lists_and_serves_custom_colormap(app, name): listed = {c["id"] for c in app.get("/colorMaps").json()["colormaps"]} - for name in make_cmaps.CMAPS: - assert name in listed - assert app.get(f"/colorMaps/{name}").status_code == 200 + + assert name in listed + assert app.get(f"/colorMaps/{name}").status_code == 200 def test_tile_renders_with_custom_colormap(app): @@ -57,5 +58,6 @@ def test_tile_renders_with_custom_colormap(app): "colormap_name": "naqfc_o3", } response = app.get("/tiles/WebMercatorQuad/0/0/0.png", params=params) + assert response.status_code == 200 assert response.headers["content-type"] == "image/png" From 31a71f2ddda84b5fbe6c97e88bb74709ba8ee206 Mon Sep 17 00:00:00 2001 From: Max Jones <14077947+maxrjones@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:34:58 +0000 Subject: [PATCH 3/8] refactor: move all colormap data into cmap_data/ with one JSON schema Every colormap is now a `cmap_data/.json` file with a `source` and either `stops` (data value -> "#rrggbb") or `ramp` + `breakpoints` (data value -> value on another file's stops). `colormaps.py` registers each file under its name and interpolates it into a 256-row table, so the ozone ramp and PM2.5 breakpoints are data rather than Python constants. Worldview's Rainbow 4 is converted to one stop per row (alpha was ff for all 255 colors; the top color is repeated for row 255), so the upstream file is no longer kept verbatim; its `source` pins the upstream commit. All three generated tables are identical to the previous ones. Tests now check every file against the schema and every stop/breakpoint list for increasing order. Co-Authored-By: Claude Opus 5.5 --- README.md | 14 +- src/titiler/multidim/cmap_data/naqfc_o3.json | 26 ++ .../multidim/cmap_data/naqfc_pm25.json | 12 + .../cmap_data/worldview_rainbow_4.json | 261 +++++++++++++++++ src/titiler/multidim/colormaps.py | 138 +++------ src/titiler/multidim/rainbow_4.json | 263 ------------------ tests/test_cmaps.py | 53 ++-- 7 files changed, 385 insertions(+), 382 deletions(-) create mode 100644 src/titiler/multidim/cmap_data/naqfc_o3.json create mode 100644 src/titiler/multidim/cmap_data/naqfc_pm25.json create mode 100644 src/titiler/multidim/cmap_data/worldview_rainbow_4.json delete mode 100644 src/titiler/multidim/rainbow_4.json diff --git a/README.md b/README.md index b31fe15..1293e58 100644 --- a/README.md +++ b/README.md @@ -77,14 +77,16 @@ TileJSON URLs work in map clients unchanged. ## Custom colormaps -Besides rio-tiler's built-in colormaps, this service registers the colormaps -built in `src/titiler/multidim/colormaps.py`. Each is a 256-row table that -titiler applies **after** `rescale` stretches the data to 0-255, so each one is -only meaningful with the `rescale` listed below. +Besides rio-tiler's built-in colormaps, this service registers every +`src/titiler/multidim/cmap_data/.json` file as colormap ``. Each +file lists color stops at data values (or, for `naqfc_pm25`, breakpoints onto +another file's stops), and `colormaps.py` interpolates it into a 256-row table +that titiler applies **after** `rescale` stretches the data to 0-255, so each +one is only meaningful with the `rescale` listed below. | `colormap_name` | Source | Use with | |---|---|---| -| `worldview_rainbow_4` | NASA Worldview custom palette "Rainbow 4", copied verbatim (255 colors) from [nasa-gibs/worldview](https://github.com/nasa-gibs/worldview/blob/7d16ee53eb38a90fe66c9860015b51094c96ed25/config/default/common/config/palettes-custom.json/rainbow_4.json) into `src/titiler/multidim/rainbow_4.json` | the value range you would set in Worldview, e.g. `rescale=0,2e16` for TEMPO NO2 tropospheric column in molecules/cm² | +| `worldview_rainbow_4` | NASA Worldview custom palette "Rainbow 4", copied verbatim (255 colors) from [nasa-gibs/worldview](https://github.com/nasa-gibs/worldview/blob/7d16ee53eb38a90fe66c9860015b51094c96ed25/config/default/common/config/palettes-custom.json/rainbow_4.json) (converted to one `#rrggbb` stop per row, top color repeated for row 255) | the value range you would set in Worldview, e.g. `rescale=0,2e16` for TEMPO NO2 tropospheric column in molecules/cm² | | `naqfc_o3` | NOAA Air Quality Forecast Guidance Viewer ozone ramp, one color per 10 ppb, sampled from the viewer for [veda-backend#665](https://github.com/NASA-IMPACT/veda-backend/pull/665) | `rescale=0,200` for data in ppb, `rescale=0,0.2` for ppm | | `naqfc_pm25` | the ozone ramp above, stretched so each EPA 24-hour PM2.5 AQI category (bounds 9.0, 35.4, 55.4, 125.4, 225.4 µg/m³) spans the same colors as its 8-hour ozone category (54, 70, 85, 105, 200 ppb) | `rescale=0,225.4` for data in µg/m³ | @@ -94,7 +96,7 @@ Known limits: provider's stated intent (Air-Quality-Portal/aq-arch#192) is category boundaries at the EPA breakpoints with a gradient inside each category; the ozone ramp as sampled does not change color exactly at those breakpoints. Replace the stops in - `colormaps.py` when the provider supplies the table. + `cmap_data/naqfc_o3.json` when the provider supplies the table. - A lookup table places category boundaries to the nearest 1/255 of the `rescale` range: 0.8 ppb for ozone, 0.9 µg/m³ for PM2.5. - Values above the `rescale` maximum take the top color; values below the minimum take diff --git a/src/titiler/multidim/cmap_data/naqfc_o3.json b/src/titiler/multidim/cmap_data/naqfc_o3.json new file mode 100644 index 0000000..e1893c2 --- /dev/null +++ b/src/titiler/multidim/cmap_data/naqfc_o3.json @@ -0,0 +1,26 @@ +{ + "source": "NOAA Air Quality Forecast Guidance Viewer ozone ramp (ppb), one color per 10 ppb, sampled by hand from the provider's screenshot for https://github.com/NASA-IMPACT/veda-backend/pull/665", + "stops": { + "0": "#AFFEB0", + "10": "#7EFE7E", + "20": "#4BFF4C", + "30": "#08F001", + "40": "#03C300", + "50": "#008700", + "60": "#E1E103", + "70": "#FEBC2E", + "80": "#E17411", + "90": "#FE7879", + "100": "#BE0001", + "110": "#F0B4E7", + "120": "#DC8CD3", + "130": "#C864BF", + "140": "#B43DAB", + "150": "#A02497", + "160": "#8B1D83", + "170": "#781470", + "180": "#640D5B", + "190": "#500347", + "200": "#3C003C" + } +} diff --git a/src/titiler/multidim/cmap_data/naqfc_pm25.json b/src/titiler/multidim/cmap_data/naqfc_pm25.json new file mode 100644 index 0000000..c646135 --- /dev/null +++ b/src/titiler/multidim/cmap_data/naqfc_pm25.json @@ -0,0 +1,12 @@ +{ + "source": "EPA AQI category breakpoints, 24-hour PM2.5 (ug/m3) mapped to 8-hour ozone (ppb), so each PM2.5 category spans the same naqfc_o3 colors as its ozone category", + "ramp": "naqfc_o3", + "breakpoints": { + "0.0": 0.0, + "9.0": 54.0, + "35.4": 70.0, + "55.4": 85.0, + "125.4": 105.0, + "225.4": 200.0 + } +} diff --git a/src/titiler/multidim/cmap_data/worldview_rainbow_4.json b/src/titiler/multidim/cmap_data/worldview_rainbow_4.json new file mode 100644 index 0000000..e76624b --- /dev/null +++ b/src/titiler/multidim/cmap_data/worldview_rainbow_4.json @@ -0,0 +1,261 @@ +{ + "source": "NASA Worldview custom palette \"Rainbow 4\" (255 colors), verbatim from https://github.com/nasa-gibs/worldview/blob/7d16ee53eb38a90fe66c9860015b51094c96ed25/config/default/common/config/palettes-custom.json/rainbow_4.json, with the top color repeated to fill row 255", + "stops": { + "0": "#3b76c6", + "1": "#3a78c6", + "2": "#3979c6", + "3": "#377cc6", + "4": "#367ec6", + "5": "#3680c6", + "6": "#3584c6", + "7": "#3587c5", + "8": "#368ac5", + "9": "#368dc4", + "10": "#3890c3", + "11": "#3a94c2", + "12": "#3c97c1", + "13": "#3f9bbf", + "14": "#429fbe", + "15": "#45a2bc", + "16": "#49a6bb", + "17": "#4ca9b9", + "18": "#51acb7", + "19": "#54afb6", + "20": "#58b2b5", + "21": "#5db5b3", + "22": "#60b8b2", + "23": "#65bbb0", + "24": "#69bdaf", + "25": "#6dc0af", + "26": "#73c2ae", + "27": "#77c4ad", + "28": "#7cc7ad", + "29": "#80c9ac", + "30": "#85caac", + "31": "#8accac", + "32": "#8eceac", + "33": "#94d0ac", + "34": "#98d2ac", + "35": "#9dd3ab", + "36": "#a2d5ab", + "37": "#a6d6aa", + "38": "#acd8a9", + "39": "#b0daa9", + "40": "#b4dba8", + "41": "#b9dda7", + "42": "#bddea6", + "43": "#c2e0a4", + "44": "#c5e1a3", + "45": "#c9e2a2", + "46": "#cde4a1", + "47": "#d1e6a1", + "48": "#d5e7a0", + "49": "#d8e9a0", + "50": "#dbeaa0", + "51": "#dfeca0", + "52": "#e2eda0", + "53": "#e5efa1", + "54": "#e7f0a3", + "55": "#eaf1a4", + "56": "#ecf3a6", + "57": "#eef4a8", + "58": "#f1f5ab", + "59": "#f2f6ad", + "60": "#f4f7af", + "61": "#f6f8b2", + "62": "#f7f9b4", + "63": "#f8f9b7", + "64": "#f9fab9", + "65": "#fafaba", + "66": "#fbfabc", + "67": "#fcfabd", + "68": "#fdf9be", + "69": "#fdf9be", + "70": "#fef8bd", + "71": "#fef7bc", + "72": "#fef6bb", + "73": "#fff5b9", + "74": "#fff3b7", + "75": "#fff2b5", + "76": "#fff0b1", + "77": "#ffefaf", + "78": "#ffedab", + "79": "#feeba7", + "80": "#fee9a4", + "81": "#fee79f", + "82": "#fee59c", + "83": "#fee397", + "84": "#fee093", + "85": "#fede90", + "86": "#fedc8c", + "87": "#fed988", + "88": "#fed784", + "89": "#fed481", + "90": "#fed27f", + "91": "#fecf7b", + "92": "#fecd79", + "93": "#feca76", + "94": "#fec773", + "95": "#fec571", + "96": "#fec16e", + "97": "#febf6c", + "98": "#febb69", + "99": "#feb867", + "100": "#feb564", + "101": "#feb262", + "102": "#feaf60", + "103": "#feab5d", + "104": "#fea75b", + "105": "#fea459", + "106": "#fea056", + "107": "#fe9d55", + "108": "#fe9953", + "109": "#fe9552", + "110": "#fe9251", + "111": "#fd8e50", + "112": "#fd8b50", + "113": "#fd8750", + "114": "#fc8450", + "115": "#fc8151", + "116": "#fb7e52", + "117": "#fa7b53", + "118": "#f97754", + "119": "#f87556", + "120": "#f67257", + "121": "#f56f59", + "122": "#f36d5a", + "123": "#f26a5c", + "124": "#f0685d", + "125": "#ef655e", + "126": "#ed6360", + "127": "#ec6061", + "128": "#ea5e62", + "129": "#e85b63", + "130": "#e75964", + "131": "#e55665", + "132": "#e35466", + "133": "#e15266", + "134": "#df4f67", + "135": "#de4d68", + "136": "#db4b69", + "137": "#d94a6a", + "138": "#d8486b", + "139": "#d5466c", + "140": "#d3456c", + "141": "#d1436e", + "142": "#cf426e", + "143": "#cd416f", + "144": "#ca4070", + "145": "#c83f71", + "146": "#c53d72", + "147": "#c33c73", + "148": "#c13b74", + "149": "#be3a75", + "150": "#bc3976", + "151": "#b93877", + "152": "#b63778", + "153": "#b43679", + "154": "#b13579", + "155": "#af347a", + "156": "#ac337b", + "157": "#a9327b", + "158": "#a7317c", + "159": "#a3307c", + "160": "#a12f7d", + "161": "#9e2e7d", + "162": "#9b2d7d", + "163": "#992c7e", + "164": "#952b7e", + "165": "#932a7e", + "166": "#90297f", + "167": "#8d287f", + "168": "#8a277f", + "169": "#872680", + "170": "#852580", + "171": "#822480", + "172": "#802480", + "173": "#7d2380", + "174": "#7a2280", + "175": "#782180", + "176": "#762080", + "177": "#731f7f", + "178": "#711e7f", + "179": "#6f1d7f", + "180": "#6d1c7f", + "181": "#6a1b7e", + "182": "#681b7e", + "183": "#661a7e", + "184": "#63197d", + "185": "#61187d", + "186": "#5f177d", + "187": "#5d167c", + "188": "#5b157c", + "189": "#58157b", + "190": "#56147a", + "191": "#541379", + "192": "#521378", + "193": "#501277", + "194": "#4d1276", + "195": "#4b1274", + "196": "#491173", + "197": "#471171", + "198": "#44116f", + "199": "#42116d", + "200": "#40116b", + "201": "#3d1169", + "202": "#3b1167", + "203": "#391165", + "204": "#371062", + "205": "#351060", + "206": "#32105e", + "207": "#30105c", + "208": "#2e105a", + "209": "#2c1057", + "210": "#2a1055", + "211": "#281053", + "212": "#261051", + "213": "#25104f", + "214": "#23104d", + "215": "#22104b", + "216": "#200f49", + "217": "#1f0f48", + "218": "#1e0f46", + "219": "#1d0f44", + "220": "#1c0e43", + "221": "#1b0e41", + "222": "#1a0e3f", + "223": "#1a0d3e", + "224": "#190d3c", + "225": "#180d3a", + "226": "#180d39", + "227": "#170c37", + "228": "#170c36", + "229": "#160c34", + "230": "#150b32", + "231": "#150b31", + "232": "#140b2f", + "233": "#130a2e", + "234": "#130a2c", + "235": "#120a2a", + "236": "#110928", + "237": "#110927", + "238": "#100925", + "239": "#100924", + "240": "#0f0822", + "241": "#0e081f", + "242": "#0d071d", + "243": "#0d071c", + "244": "#0c071a", + "245": "#0b0618", + "246": "#0b0617", + "247": "#0a0616", + "248": "#0a0614", + "249": "#090513", + "250": "#080511", + "251": "#080510", + "252": "#08040f", + "253": "#07040e", + "254": "#07040d", + "255": "#07040d" + } +} diff --git a/src/titiler/multidim/colormaps.py b/src/titiler/multidim/colormaps.py index fe4ee1b..d3e190f 100644 --- a/src/titiler/multidim/colormaps.py +++ b/src/titiler/multidim/colormaps.py @@ -1,128 +1,74 @@ """Custom colormaps, registered alongside rio-tiler's built-ins. -Each colormap is a 256-row table that titiler applies after ``rescale`` -stretches the data to 0-255, so each one is only meaningful with the -``rescale`` listed in the README's "Custom colormaps" section. +Every ``cmap_data/.json`` file is registered as colormap ````. Each +file has a ``source`` and either: + +- ``stops``: data values mapped to ``#rrggbb`` colors, or +- ``ramp`` and ``breakpoints``: another file's name, and data values mapped to + values on that file's stops. + +Each colormap is a 256-row table spanning its smallest to largest data value, +which titiler applies after ``rescale`` stretches the data to 0-255, so each one +is only meaningful with the ``rescale`` listed in the README's "Custom +colormaps" section. """ import json from collections.abc import Mapping from importlib.resources import files +from typing import Any import numpy as np from rio_tiler.colormap import cmap as default_cmap from rio_tiler.colormap import parse_color from rio_tiler.types import GDALColorMapType -# The ozone ramp's color at every 10 ppb, sampled by hand from the provider's -# screenshot of the NOAA Air Quality Forecast Guidance Viewer for -# NASA-IMPACT/veda-backend#665, the pull request adding these colormaps to -# veda-backend. -O3_RAMP_PPB: dict[float, str] = { - 0: "#AFFEB0", - 10: "#7EFE7E", - 20: "#4BFF4C", - 30: "#08F001", - 40: "#03C300", - 50: "#008700", - 60: "#E1E103", - 70: "#FEBC2E", - 80: "#E17411", - 90: "#FE7879", - 100: "#BE0001", - 110: "#F0B4E7", - 120: "#DC8CD3", - 130: "#C864BF", - 140: "#B43DAB", - 150: "#A02497", - 160: "#8B1D83", - 170: "#781470", - 180: "#640D5B", - 190: "#500347", - 200: "#3C003C", -} -# EPA AQI breakpoints, as a 24-hour PM2.5 concentration (ug/m3) and as an 8-hour -# ozone concentration (ppb). Each entry after the first is where the labeled -# category ends. -PM25_UGM3_TO_O3_PPB: dict[float, float] = { - 0.0: 0.0, - 9.0: 54.0, # Good - 35.4: 70.0, # Moderate - 55.4: 85.0, # Unhealthy for Sensitive Groups - 125.4: 105.0, # Unhealthy - 225.4: 200.0, # Very Unhealthy -} +def _points(mapping: Mapping[str, Any]) -> list[float]: + """The data values (JSON keys) of ``mapping``, as numbers.""" + return [float(k) for k in mapping] def _colormap_from_ramp( - ramp: Mapping[float, str], values: np.ndarray + stops: Mapping[str, str], values: np.ndarray ) -> GDALColorMapType: - """Turn ``ramp`` into a colormap with one row for each item in ``values``. + """Turn ``stops`` into a colormap with one row for each item in ``values``. - ``ramp`` maps a few data values, its stops, to colors. Row ``i`` of the - result is the color at ``values[i]``, blended from the colors of the two - nearest stops when ``values[i]`` falls between them. Each colormap in this - module passes 256 values, one for each pixel value that ``rescale`` - produces. + Row ``i`` of the result is the color at ``values[i]``, blended from the + colors of the two nearest stops when ``values[i]`` falls between them. - >>> ramp = {0: "#000000", 10: "#FFFFFF"} # black at 0, white at 10 - >>> _colormap_from_ramp(ramp, np.array([0, 5, 10])) + >>> stops = {"0": "#000000", "10": "#FFFFFF"} # black at 0, white at 10 + >>> _colormap_from_ramp(stops, np.array([0, 5, 10])) {0: (0, 0, 0, 255), 1: (128, 128, 128, 255), 2: (255, 255, 255, 255)} """ - colors = np.array([parse_color(c) for c in ramp.values()]) - channels = [np.interp(values, list(ramp), channel) for channel in colors.T] + colors = np.array([parse_color(c) for c in stops.values()]) + channels = [np.interp(values, _points(stops), channel) for channel in colors.T] rows = np.rint(np.stack(channels, axis=1)).astype(int).tolist() return dict(enumerate(map(tuple, rows))) -def _o3_equivalent_ppb(ugm3: np.ndarray) -> np.ndarray: - """Map each PM2.5 value (ug/m3) to its ozone equivalent (ppb). - - The equivalent sits at the same relative position within the same AQI - category, so PM2.5 and ozone in the same category get the same colors. For - example, 22.2 ug/m3 is halfway through PM2.5's Moderate category (9.0 to - 35.4), so its equivalent is 62 ppb, halfway through ozone's (54 to 70). +def _build(spec: Mapping[str, Any], specs: Mapping[str, Any]) -> GDALColorMapType: + """Build the 256-row colormap described by one ``cmap_data`` file.""" + if "stops" in spec: + points = _points(spec["stops"]) + return _colormap_from_ramp( + spec["stops"], np.linspace(points[0], points[-1], 256) + ) - >>> _o3_equivalent_ppb(np.array([9.0, 22.2, 35.4])) - array([54., 62., 70.]) - """ - return np.interp( - ugm3, list(PM25_UGM3_TO_O3_PPB), list(PM25_UGM3_TO_O3_PPB.values()) + points = _points(spec["breakpoints"]) + ramp_values = np.interp( + np.linspace(points[0], points[-1], 256), + points, + list(spec["breakpoints"].values()), ) + return _colormap_from_ramp(specs[spec["ramp"]]["stops"], ramp_values) -def worldview_rainbow_4() -> GDALColorMapType: - """NASA Worldview custom palette "Rainbow 4", verbatim. - - The palette has 255 colors, and rio-tiler would leave a 255-row colormap's - top value transparent, so the top color is repeated to fill 256 rows. - """ - source = json.loads((files(__package__) / "rainbow_4.json").read_text()) - colors = [parse_color(f"#{c}") for c in source["rainbow_4"]["colors"]] - - return dict(enumerate([*colors, colors[-1]])) - - -def naqfc_o3() -> GDALColorMapType: - """NAQFC ozone ramp, from 0 ppb to its top stop.""" - ppb = np.linspace(0, max(O3_RAMP_PPB), 256) - - return _colormap_from_ramp(O3_RAMP_PPB, ppb) - - -def naqfc_pm25() -> GDALColorMapType: - """NAQFC PM2.5 ramp: the ozone ramp, stretched over the PM2.5 AQI categories.""" - ugm3 = np.linspace(0, max(PM25_UGM3_TO_O3_PPB), 256) - - return _colormap_from_ramp(O3_RAMP_PPB, _o3_equivalent_ppb(ugm3)) - +SPECS: dict[str, Any] = { + path.name.removesuffix(".json"): json.loads(path.read_text()) + for path in (files(__package__) / "cmap_data").iterdir() + if path.name.endswith(".json") +} -cmap = default_cmap.register( - { - "worldview_rainbow_4": worldview_rainbow_4(), - "naqfc_o3": naqfc_o3(), - "naqfc_pm25": naqfc_pm25(), - } -) +cmap = default_cmap.register({name: _build(s, SPECS) for name, s in SPECS.items()}) diff --git a/src/titiler/multidim/rainbow_4.json b/src/titiler/multidim/rainbow_4.json deleted file mode 100644 index 02326b2..0000000 --- a/src/titiler/multidim/rainbow_4.json +++ /dev/null @@ -1,263 +0,0 @@ -{ - "rainbow_4": { - "id": "rainbow_4", - "name": "Rainbow 4", - "colors": [ - "3b76c6ff", - "3a78c6ff", - "3979c6ff", - "377cc6ff", - "367ec6ff", - "3680c6ff", - "3584c6ff", - "3587c5ff", - "368ac5ff", - "368dc4ff", - "3890c3ff", - "3a94c2ff", - "3c97c1ff", - "3f9bbfff", - "429fbeff", - "45a2bcff", - "49a6bbff", - "4ca9b9ff", - "51acb7ff", - "54afb6ff", - "58b2b5ff", - "5db5b3ff", - "60b8b2ff", - "65bbb0ff", - "69bdafff", - "6dc0afff", - "73c2aeff", - "77c4adff", - "7cc7adff", - "80c9acff", - "85caacff", - "8accacff", - "8eceacff", - "94d0acff", - "98d2acff", - "9dd3abff", - "a2d5abff", - "a6d6aaff", - "acd8a9ff", - "b0daa9ff", - "b4dba8ff", - "b9dda7ff", - "bddea6ff", - "c2e0a4ff", - "c5e1a3ff", - "c9e2a2ff", - "cde4a1ff", - "d1e6a1ff", - "d5e7a0ff", - "d8e9a0ff", - "dbeaa0ff", - "dfeca0ff", - "e2eda0ff", - "e5efa1ff", - "e7f0a3ff", - "eaf1a4ff", - "ecf3a6ff", - "eef4a8ff", - "f1f5abff", - "f2f6adff", - "f4f7afff", - "f6f8b2ff", - "f7f9b4ff", - "f8f9b7ff", - "f9fab9ff", - "fafabaff", - "fbfabcff", - "fcfabdff", - "fdf9beff", - "fdf9beff", - "fef8bdff", - "fef7bcff", - "fef6bbff", - "fff5b9ff", - "fff3b7ff", - "fff2b5ff", - "fff0b1ff", - "ffefafff", - "ffedabff", - "feeba7ff", - "fee9a4ff", - "fee79fff", - "fee59cff", - "fee397ff", - "fee093ff", - "fede90ff", - "fedc8cff", - "fed988ff", - "fed784ff", - "fed481ff", - "fed27fff", - "fecf7bff", - "fecd79ff", - "feca76ff", - "fec773ff", - "fec571ff", - "fec16eff", - "febf6cff", - "febb69ff", - "feb867ff", - "feb564ff", - "feb262ff", - "feaf60ff", - "feab5dff", - "fea75bff", - "fea459ff", - "fea056ff", - "fe9d55ff", - "fe9953ff", - "fe9552ff", - "fe9251ff", - "fd8e50ff", - "fd8b50ff", - "fd8750ff", - "fc8450ff", - "fc8151ff", - "fb7e52ff", - "fa7b53ff", - "f97754ff", - "f87556ff", - "f67257ff", - "f56f59ff", - "f36d5aff", - "f26a5cff", - "f0685dff", - "ef655eff", - "ed6360ff", - "ec6061ff", - "ea5e62ff", - "e85b63ff", - "e75964ff", - "e55665ff", - "e35466ff", - "e15266ff", - "df4f67ff", - "de4d68ff", - "db4b69ff", - "d94a6aff", - "d8486bff", - "d5466cff", - "d3456cff", - "d1436eff", - "cf426eff", - "cd416fff", - "ca4070ff", - "c83f71ff", - "c53d72ff", - "c33c73ff", - "c13b74ff", - "be3a75ff", - "bc3976ff", - "b93877ff", - "b63778ff", - "b43679ff", - "b13579ff", - "af347aff", - "ac337bff", - "a9327bff", - "a7317cff", - "a3307cff", - "a12f7dff", - "9e2e7dff", - "9b2d7dff", - "992c7eff", - "952b7eff", - "932a7eff", - "90297fff", - "8d287fff", - "8a277fff", - "872680ff", - "852580ff", - "822480ff", - "802480ff", - "7d2380ff", - "7a2280ff", - "782180ff", - "762080ff", - "731f7fff", - "711e7fff", - "6f1d7fff", - "6d1c7fff", - "6a1b7eff", - "681b7eff", - "661a7eff", - "63197dff", - "61187dff", - "5f177dff", - "5d167cff", - "5b157cff", - "58157bff", - "56147aff", - "541379ff", - "521378ff", - "501277ff", - "4d1276ff", - "4b1274ff", - "491173ff", - "471171ff", - "44116fff", - "42116dff", - "40116bff", - "3d1169ff", - "3b1167ff", - "391165ff", - "371062ff", - "351060ff", - "32105eff", - "30105cff", - "2e105aff", - "2c1057ff", - "2a1055ff", - "281053ff", - "261051ff", - "25104fff", - "23104dff", - "22104bff", - "200f49ff", - "1f0f48ff", - "1e0f46ff", - "1d0f44ff", - "1c0e43ff", - "1b0e41ff", - "1a0e3fff", - "1a0d3eff", - "190d3cff", - "180d3aff", - "180d39ff", - "170c37ff", - "170c36ff", - "160c34ff", - "150b32ff", - "150b31ff", - "140b2fff", - "130a2eff", - "130a2cff", - "120a2aff", - "110928ff", - "110927ff", - "100925ff", - "100924ff", - "0f0822ff", - "0e081fff", - "0d071dff", - "0d071cff", - "0c071aff", - "0b0618ff", - "0b0617ff", - "0a0616ff", - "0a0614ff", - "090513ff", - "080511ff", - "080510ff", - "08040fff", - "07040eff", - "07040dff" - ] - } -} diff --git a/tests/test_cmaps.py b/tests/test_cmaps.py index 701dd32..1f45a13 100644 --- a/tests/test_cmaps.py +++ b/tests/test_cmaps.py @@ -1,7 +1,6 @@ """Custom colormaps: the tables keep their stated properties and the API serves them.""" -import json -from importlib.resources import files +import re from itertools import pairwise import pytest @@ -10,27 +9,47 @@ from titiler.multidim import colormaps +@pytest.mark.parametrize("name", list(colormaps.SPECS)) +def test_file_follows_schema(name): + spec = colormaps.SPECS[name] + + assert isinstance(spec["source"], str) + if "stops" in spec: + assert set(spec) == {"source", "stops"} + assert all(re.fullmatch(r"#[0-9A-Fa-f]{6}", c) for c in spec["stops"].values()) + else: + assert set(spec) == {"source", "ramp", "breakpoints"} + assert "stops" in colormaps.SPECS[spec["ramp"]] + + def test_rainbow_4_is_verbatim_worldview(): - source = json.loads((files("titiler.multidim") / "rainbow_4.json").read_text()) - colors = [tuple(bytes.fromhex(c)) for c in source["rainbow_4"]["colors"]] + stops = colormaps.SPECS["worldview_rainbow_4"]["stops"] rainbow = colormaps.cmap.get("worldview_rainbow_4") # After rescale, pixel values run from 0 to 255, and rio-tiler leaves any # value without a row transparent, so the colormap needs all 256 rows. assert len(rainbow) == 256 - # Every Worldview color in order, then the top one again to fill row 255. - assert list(rainbow.values()) == [*colors, colors[-1]] - - -@pytest.mark.parametrize( - "points", - [ - list(colormaps.O3_RAMP_PPB), - list(colormaps.PM25_UGM3_TO_O3_PPB), - list(colormaps.PM25_UGM3_TO_O3_PPB.values()), - ], - ids=["ozone ramp", "PM2.5 breakpoints", "ozone breakpoints"], -) + # One stop per row, so every row is a stop's color, unblended. + assert list(rainbow.values()) == [ + (*bytes.fromhex(c[1:]), 255) for c in stops.values() + ] + # Worldview's first and last colors, the last repeated to fill row 255. + assert rainbow[0] == (0x3B, 0x76, 0xC6, 255) + assert rainbow[254] == rainbow[255] == (0x07, 0x04, 0x0D, 255) + + +def _interpolation_points(): + for name, spec in colormaps.SPECS.items(): + for key in ("stops", "breakpoints"): + if key in spec: + yield pytest.param([float(k) for k in spec[key]], id=f"{name} {key}") + if "breakpoints" in spec: + yield pytest.param( + list(spec["breakpoints"].values()), id=f"{name} breakpoint values" + ) + + +@pytest.mark.parametrize("points", _interpolation_points()) def test_interpolation_points_increase(points): # np.interp does not check that the points it interpolates between increase, # so an out-of-order entry silently scrambles a colormap rather than failing. From 3fd3b372cb741db1b5bba9b7f3feb23b9c4f10fc Mon Sep 17 00:00:00 2001 From: Max Jones <14077947+maxrjones@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:44:53 +0000 Subject: [PATCH 4/8] docs: keep per-colormap notes in cmap_data, user-facing ranges in README Add an optional `notes` field to the cmap_data schema and move the NAQFC screenshot-sampling limit into naqfc_o3.json (naqfc_pm25.json points to it), next to the stops it describes. The README keeps what API users need, since /colorMaps serves only the table: each colormap's `rescale`, a one-line description linking to its file, and the limits that apply to every custom colormap. Provenance details live only in each file's `source`. Co-Authored-By: Claude Opus 5.5 --- README.md | 34 ++++++++----------- src/titiler/multidim/cmap_data/naqfc_o3.json | 1 + .../multidim/cmap_data/naqfc_pm25.json | 1 + src/titiler/multidim/colormaps.py | 2 +- tests/test_cmaps.py | 6 ++-- 5 files changed, 21 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 1293e58..a5707a7 100644 --- a/README.md +++ b/README.md @@ -79,28 +79,22 @@ TileJSON URLs work in map clients unchanged. Besides rio-tiler's built-in colormaps, this service registers every `src/titiler/multidim/cmap_data/.json` file as colormap ``. Each -file lists color stops at data values (or, for `naqfc_pm25`, breakpoints onto -another file's stops), and `colormaps.py` interpolates it into a 256-row table -that titiler applies **after** `rescale` stretches the data to 0-255, so each -one is only meaningful with the `rescale` listed below. +file records its `source`, any `notes` on its limits, and either color `stops` +at data values or `breakpoints` onto another file's stops; `colormaps.py` +interpolates it into a 256-row table. titiler applies that table **after** +`rescale` stretches the data to 0-255, so each colormap is only meaningful with +the `rescale` listed below. -| `colormap_name` | Source | Use with | +| `colormap_name` | What it is | Use with | |---|---|---| -| `worldview_rainbow_4` | NASA Worldview custom palette "Rainbow 4", copied verbatim (255 colors) from [nasa-gibs/worldview](https://github.com/nasa-gibs/worldview/blob/7d16ee53eb38a90fe66c9860015b51094c96ed25/config/default/common/config/palettes-custom.json/rainbow_4.json) (converted to one `#rrggbb` stop per row, top color repeated for row 255) | the value range you would set in Worldview, e.g. `rescale=0,2e16` for TEMPO NO2 tropospheric column in molecules/cm² | -| `naqfc_o3` | NOAA Air Quality Forecast Guidance Viewer ozone ramp, one color per 10 ppb, sampled from the viewer for [veda-backend#665](https://github.com/NASA-IMPACT/veda-backend/pull/665) | `rescale=0,200` for data in ppb, `rescale=0,0.2` for ppm | -| `naqfc_pm25` | the ozone ramp above, stretched so each EPA 24-hour PM2.5 AQI category (bounds 9.0, 35.4, 55.4, 125.4, 225.4 µg/m³) spans the same colors as its 8-hour ozone category (54, 70, 85, 105, 200 ppb) | `rescale=0,225.4` for data in µg/m³ | - -Known limits: - -- The NAQFC ramps are sampled from a screenshot, not from a provider color table. The - provider's stated intent (Air-Quality-Portal/aq-arch#192) is category boundaries at - the EPA breakpoints with a gradient inside each category; the ozone ramp as sampled - does not change color exactly at those breakpoints. Replace the stops in - `cmap_data/naqfc_o3.json` when the provider supplies the table. -- A lookup table places category boundaries to the nearest 1/255 of the `rescale` - range: 0.8 ppb for ozone, 0.9 µg/m³ for PM2.5. -- Values above the `rescale` maximum take the top color; values below the minimum take - the bottom color. +| [`worldview_rainbow_4`](src/titiler/multidim/cmap_data/worldview_rainbow_4.json) | NASA Worldview's "Rainbow 4" palette | the value range you would set in Worldview, e.g. `rescale=0,2e16` for TEMPO NO2 tropospheric column in molecules/cm² | +| [`naqfc_o3`](src/titiler/multidim/cmap_data/naqfc_o3.json) | NOAA Air Quality Forecast Guidance Viewer ozone ramp | `rescale=0,200` for data in ppb, `rescale=0,0.2` for ppm | +| [`naqfc_pm25`](src/titiler/multidim/cmap_data/naqfc_pm25.json) | the ozone ramp, stretched so each EPA PM2.5 AQI category gets its ozone category's colors | `rescale=0,225.4` for data in µg/m³ | + +Every custom colormap places category boundaries to the nearest 1/255 of the +`rescale` range (0.8 ppb for ozone, 0.9 µg/m³ for PM2.5), and gives values +above the `rescale` maximum the top color and values below the minimum the +bottom color. ```bash curl -G "$API_URL/tiles/WebMercatorQuad/4/3/6.png" \ diff --git a/src/titiler/multidim/cmap_data/naqfc_o3.json b/src/titiler/multidim/cmap_data/naqfc_o3.json index e1893c2..e0580dd 100644 --- a/src/titiler/multidim/cmap_data/naqfc_o3.json +++ b/src/titiler/multidim/cmap_data/naqfc_o3.json @@ -1,5 +1,6 @@ { "source": "NOAA Air Quality Forecast Guidance Viewer ozone ramp (ppb), one color per 10 ppb, sampled by hand from the provider's screenshot for https://github.com/NASA-IMPACT/veda-backend/pull/665", + "notes": "Sampled from a screenshot, not from a provider color table. The provider's stated intent (Air-Quality-Portal/aq-arch#192) is category boundaries at the EPA breakpoints (54, 70, 85, 105, 200 ppb) with a gradient inside each category; these stops do not change color exactly at those breakpoints. Replace them when the provider supplies the table.", "stops": { "0": "#AFFEB0", "10": "#7EFE7E", diff --git a/src/titiler/multidim/cmap_data/naqfc_pm25.json b/src/titiler/multidim/cmap_data/naqfc_pm25.json index c646135..0e6f1a3 100644 --- a/src/titiler/multidim/cmap_data/naqfc_pm25.json +++ b/src/titiler/multidim/cmap_data/naqfc_pm25.json @@ -1,5 +1,6 @@ { "source": "EPA AQI category breakpoints, 24-hour PM2.5 (ug/m3) mapped to 8-hour ozone (ppb), so each PM2.5 category spans the same naqfc_o3 colors as its ozone category", + "notes": "Borrows the naqfc_o3 stops, so it shares their limits: see that file's notes.", "ramp": "naqfc_o3", "breakpoints": { "0.0": 0.0, diff --git a/src/titiler/multidim/colormaps.py b/src/titiler/multidim/colormaps.py index d3e190f..28931f4 100644 --- a/src/titiler/multidim/colormaps.py +++ b/src/titiler/multidim/colormaps.py @@ -1,7 +1,7 @@ """Custom colormaps, registered alongside rio-tiler's built-ins. Every ``cmap_data/.json`` file is registered as colormap ````. Each -file has a ``source`` and either: +file has a ``source``, optional ``notes`` on its limits, and either: - ``stops``: data values mapped to ``#rrggbb`` colors, or - ``ramp`` and ``breakpoints``: another file's name, and data values mapped to diff --git a/tests/test_cmaps.py b/tests/test_cmaps.py index 1f45a13..9cea8a7 100644 --- a/tests/test_cmaps.py +++ b/tests/test_cmaps.py @@ -14,11 +14,13 @@ def test_file_follows_schema(name): spec = colormaps.SPECS[name] assert isinstance(spec["source"], str) + assert isinstance(spec.get("notes", ""), str) + keys = set(spec) - {"source", "notes"} if "stops" in spec: - assert set(spec) == {"source", "stops"} + assert keys == {"stops"} assert all(re.fullmatch(r"#[0-9A-Fa-f]{6}", c) for c in spec["stops"].values()) else: - assert set(spec) == {"source", "ramp", "breakpoints"} + assert keys == {"ramp", "breakpoints"} assert "stops" in colormaps.SPECS[spec["ramp"]] From 20617d5714a7ae6b33e961dc6e2a9317cde60e0c Mon Sep 17 00:00:00 2001 From: Max Jones <14077947+maxrjones@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:50:48 +0000 Subject: [PATCH 5/8] feat: add a required reference URL to the cmap_data schema Each colormap file now has a `reference` (an https URL) alongside its `source` text, which no longer embeds links: the pinned Worldview file, veda-backend#665 for the NAQFC ozone stops, and the AirNow AQI Technical Assistance Document for the PM2.5 breakpoints. The schema test checks it. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- src/titiler/multidim/cmap_data/naqfc_o3.json | 3 ++- src/titiler/multidim/cmap_data/naqfc_pm25.json | 1 + src/titiler/multidim/cmap_data/worldview_rainbow_4.json | 3 ++- src/titiler/multidim/colormaps.py | 3 ++- tests/test_cmaps.py | 4 +++- 6 files changed, 13 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a5707a7..adecdef 100644 --- a/README.md +++ b/README.md @@ -79,9 +79,9 @@ TileJSON URLs work in map clients unchanged. Besides rio-tiler's built-in colormaps, this service registers every `src/titiler/multidim/cmap_data/.json` file as colormap ``. Each -file records its `source`, any `notes` on its limits, and either color `stops` -at data values or `breakpoints` onto another file's stops; `colormaps.py` -interpolates it into a 256-row table. titiler applies that table **after** +file records its `source`, a `reference` URL, any `notes` on its limits, and +either color `stops` at data values or `breakpoints` onto another file's stops; +`colormaps.py` interpolates it into a 256-row table. titiler applies that table **after** `rescale` stretches the data to 0-255, so each colormap is only meaningful with the `rescale` listed below. diff --git a/src/titiler/multidim/cmap_data/naqfc_o3.json b/src/titiler/multidim/cmap_data/naqfc_o3.json index e0580dd..2765637 100644 --- a/src/titiler/multidim/cmap_data/naqfc_o3.json +++ b/src/titiler/multidim/cmap_data/naqfc_o3.json @@ -1,5 +1,6 @@ { - "source": "NOAA Air Quality Forecast Guidance Viewer ozone ramp (ppb), one color per 10 ppb, sampled by hand from the provider's screenshot for https://github.com/NASA-IMPACT/veda-backend/pull/665", + "source": "NOAA Air Quality Forecast Guidance Viewer ozone ramp (ppb), one color per 10 ppb, sampled by hand from the provider's screenshot in the referenced pull request", + "reference": "https://github.com/NASA-IMPACT/veda-backend/pull/665", "notes": "Sampled from a screenshot, not from a provider color table. The provider's stated intent (Air-Quality-Portal/aq-arch#192) is category boundaries at the EPA breakpoints (54, 70, 85, 105, 200 ppb) with a gradient inside each category; these stops do not change color exactly at those breakpoints. Replace them when the provider supplies the table.", "stops": { "0": "#AFFEB0", diff --git a/src/titiler/multidim/cmap_data/naqfc_pm25.json b/src/titiler/multidim/cmap_data/naqfc_pm25.json index 0e6f1a3..869e878 100644 --- a/src/titiler/multidim/cmap_data/naqfc_pm25.json +++ b/src/titiler/multidim/cmap_data/naqfc_pm25.json @@ -1,5 +1,6 @@ { "source": "EPA AQI category breakpoints, 24-hour PM2.5 (ug/m3) mapped to 8-hour ozone (ppb), so each PM2.5 category spans the same naqfc_o3 colors as its ozone category", + "reference": "https://document.airnow.gov/technical-assistance-document-for-the-reporting-of-daily-air-quailty.pdf", "notes": "Borrows the naqfc_o3 stops, so it shares their limits: see that file's notes.", "ramp": "naqfc_o3", "breakpoints": { diff --git a/src/titiler/multidim/cmap_data/worldview_rainbow_4.json b/src/titiler/multidim/cmap_data/worldview_rainbow_4.json index e76624b..8de44b0 100644 --- a/src/titiler/multidim/cmap_data/worldview_rainbow_4.json +++ b/src/titiler/multidim/cmap_data/worldview_rainbow_4.json @@ -1,5 +1,6 @@ { - "source": "NASA Worldview custom palette \"Rainbow 4\" (255 colors), verbatim from https://github.com/nasa-gibs/worldview/blob/7d16ee53eb38a90fe66c9860015b51094c96ed25/config/default/common/config/palettes-custom.json/rainbow_4.json, with the top color repeated to fill row 255", + "source": "NASA Worldview custom palette \"Rainbow 4\" (255 colors), verbatim at the referenced commit, with the top color repeated to fill row 255", + "reference": "https://github.com/nasa-gibs/worldview/blob/7d16ee53eb38a90fe66c9860015b51094c96ed25/config/default/common/config/palettes-custom.json/rainbow_4.json", "stops": { "0": "#3b76c6", "1": "#3a78c6", diff --git a/src/titiler/multidim/colormaps.py b/src/titiler/multidim/colormaps.py index 28931f4..8bac43f 100644 --- a/src/titiler/multidim/colormaps.py +++ b/src/titiler/multidim/colormaps.py @@ -1,7 +1,8 @@ """Custom colormaps, registered alongside rio-tiler's built-ins. Every ``cmap_data/.json`` file is registered as colormap ````. Each -file has a ``source``, optional ``notes`` on its limits, and either: +file has a ``source``, a ``reference`` URL for it, optional ``notes`` on its +limits, and either: - ``stops``: data values mapped to ``#rrggbb`` colors, or - ``ramp`` and ``breakpoints``: another file's name, and data values mapped to diff --git a/tests/test_cmaps.py b/tests/test_cmaps.py index 9cea8a7..bb0bb9d 100644 --- a/tests/test_cmaps.py +++ b/tests/test_cmaps.py @@ -2,6 +2,7 @@ import re from itertools import pairwise +from urllib.parse import urlparse import pytest from test_app import store_params @@ -14,8 +15,9 @@ def test_file_follows_schema(name): spec = colormaps.SPECS[name] assert isinstance(spec["source"], str) + assert urlparse(spec["reference"]).scheme == "https" assert isinstance(spec.get("notes", ""), str) - keys = set(spec) - {"source", "notes"} + keys = set(spec) - {"source", "reference", "notes"} if "stops" in spec: assert keys == {"stops"} assert all(re.fullmatch(r"#[0-9A-Fa-f]{6}", c) for c in spec["stops"].values()) From 184d397462e86cae5d96cd7191fa51815dd15421 Mon Sep 17 00:00:00 2001 From: Max Jones <14077947+maxrjones@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:53:21 +0000 Subject: [PATCH 6/8] docs: cite the EPA AQS AQI breakpoint table for naqfc_pm25 The PM2.5 (88101, 24 HOUR) and 8-hour ozone (44201) breakpoints in naqfc_pm25.json and the naqfc_o3 notes match this table exactly (9.0/35.4/55.4/125.4/225.4 ug/m3; 54/70/85/105/200 ppb). Co-Authored-By: Claude Opus 5.5 --- src/titiler/multidim/cmap_data/naqfc_pm25.json | 2 +- uv.lock | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/titiler/multidim/cmap_data/naqfc_pm25.json b/src/titiler/multidim/cmap_data/naqfc_pm25.json index 869e878..bffd273 100644 --- a/src/titiler/multidim/cmap_data/naqfc_pm25.json +++ b/src/titiler/multidim/cmap_data/naqfc_pm25.json @@ -1,6 +1,6 @@ { "source": "EPA AQI category breakpoints, 24-hour PM2.5 (ug/m3) mapped to 8-hour ozone (ppb), so each PM2.5 category spans the same naqfc_o3 colors as its ozone category", - "reference": "https://document.airnow.gov/technical-assistance-document-for-the-reporting-of-daily-air-quailty.pdf", + "reference": "https://aqs.epa.gov/aqsweb/documents/codetables/aqi_breakpoints.html", "notes": "Borrows the naqfc_o3 stops, so it shares their limits: see that file's notes.", "ramp": "naqfc_o3", "breakpoints": { diff --git a/uv.lock b/uv.lock index f11535c..f33cc2e 100644 --- a/uv.lock +++ b/uv.lock @@ -12,6 +12,10 @@ resolution-markers = [ "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P1W" + [[package]] name = "affine" version = "2.4.0" @@ -3416,7 +3420,7 @@ wheels = [ [[package]] name = "titiler-multidim" -version = "0.10.1" +version = "0.10.2" source = { editable = "." } dependencies = [ { name = "aiobotocore" }, From 94e58fc87eadda9176894fb9fafb09967111d4eb Mon Sep 17 00:00:00 2001 From: Max Jones <14077947+maxrjones@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:41:10 -0400 Subject: [PATCH 7/8] Apply batched suggestions from code review Co-authored-by: Chuck Daniels --- tests/test_cmaps.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_cmaps.py b/tests/test_cmaps.py index bb0bb9d..0beffb9 100644 --- a/tests/test_cmaps.py +++ b/tests/test_cmaps.py @@ -10,10 +10,8 @@ from titiler.multidim import colormaps -@pytest.mark.parametrize("name", list(colormaps.SPECS)) -def test_file_follows_schema(name): - spec = colormaps.SPECS[name] - +@pytest.mark.parametrize("spec", colormaps.SPECS.values(), ids=colormaps.SPECS.keys()) +def test_file_follows_schema(spec): assert isinstance(spec["source"], str) assert urlparse(spec["reference"]).scheme == "https" assert isinstance(spec.get("notes", ""), str) @@ -32,6 +30,8 @@ def test_rainbow_4_is_verbatim_worldview(): # After rescale, pixel values run from 0 to 255, and rio-tiler leaves any # value without a row transparent, so the colormap needs all 256 rows. + # A 256-row table, not the list of value ranges rio-tiler also accepts. + assert isinstance(rainbow, dict) assert len(rainbow) == 256 # One stop per row, so every row is a stop's color, unblended. assert list(rainbow.values()) == [ @@ -53,7 +53,7 @@ def _interpolation_points(): ) -@pytest.mark.parametrize("points", _interpolation_points()) +@pytest.mark.parametrize("points", list(_interpolation_points())) def test_interpolation_points_increase(points): # np.interp does not check that the points it interpolates between increase, # so an out-of-order entry silently scrambles a colormap rather than failing. From 83daadcebc5a0edbe58ebd87936d6963c6c6c367 Mon Sep 17 00:00:00 2001 From: Max Jones <14077947+maxrjones@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:17:45 +0000 Subject: [PATCH 8/8] Pin uv exclude-newer and reject duplicate JSON keys Set exclude-newer to "1 week" under [tool.uv]. The [options] block in uv.lock then comes from the project instead of a contributor's global uv config. A plain `uv sync` no longer strips it. Add the check-json hook from pre-commit-hooks. json.loads keeps the last of two duplicate keys without warning. A mistyped breakpoint key in cmap_data would otherwise replace an earlier entry silently. Co-authored-by: Chuck Daniels --- .pre-commit-config.yaml | 5 +++++ pyproject.toml | 3 +++ 2 files changed, 8 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e3f29ad..e13e249 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,9 @@ repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-json + - repo: https://github.com/abravalheri/validate-pyproject rev: v0.23 hooks: diff --git a/pyproject.toml b/pyproject.toml index aa9697e..e008dc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -143,6 +143,9 @@ strict_optional = true namespace_packages = true explicit_package_bases = true +[tool.uv] +exclude-newer = "1 week" + [build-system] requires = ["pdm-backend"] build-backend = "pdm.backend"