diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d5349e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +dist/ +build/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1369afc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 pyncmap contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 13d88ca..e34248d 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,63 @@ -# pyNCmap +# pyncmap -Use NCL colortable in python matplotlib colormap without install new packages +NCL 스타일(`*.rgb`, `*.bound`) colormap 파일을 Python Matplotlib에서 바로 사용할 수 있게 해주는 라이브러리입니다. -All colortable in NCL exist in cmap_data.tar.gz (Ref: https://www.ncl.ucar.edu/Document/Graphics/color_table_gallery.shtml) +## 설치 -Unzip the compressed file and place on the same directory with the python code +```bash +pip install pyncmap +``` -No compatibility issue +로컬 개발 모드: -You can use reversed colormap for any colortable just by typing "_r" after the colortable name (i.e., rainbow_r) +```bash +pip install -e . +``` + +## 사용법 + +```python +import matplotlib.pyplot as plt +import numpy as np +from pyncmap import ColorMapMaker + +data = np.random.rand(50, 50) * 60 + +cm = ColorMapMaker("KMA_radar") +cmap, norm, boundaries = cm.get_all() + +plt.pcolormesh(data, cmap=cmap, norm=norm) +plt.colorbar(boundaries=boundaries) +plt.show() +``` + +reverse colormap은 이름 끝에 `_r`를 붙여 사용합니다. + +```python +cm = ColorMapMaker("KMA_radar_r") +``` + +## API + +- `ColorMapMaker(name, data_dir=None)` +- `get_colormap(name, data_dir=None)` → `(cmap, norm, boundaries)` +- `list_available_colormaps(data_dir=None)` + +## 포함 데이터 + +현재 저장소에는 예시로 KMA 계열 colormap 데이터가 포함되어 있습니다. + +- `KMA_radar`: 기상청 레이더 관측 및 초단기 예보 자료에 사용하는 colormap +- `KMA_precip1224`: 기상청 AWS 관측(12시간/24시간 누적 강수 관측)에 사용하는 colormap +- `KMA_precip1560`: 기상청 AWS 관측(15분 관측/60분 누적 강수 관측)에 사용하는 colormap + +## 개발 + +```bash +pytest +python -m build +``` + +## 레거시 호환 + +기존 `ncl_colormap.py` import도 유지되며, 내부적으로 `pyncmap` 패키지를 사용합니다. diff --git a/__pycache__/ncl_colormap.cpython-39.pyc b/__pycache__/ncl_colormap.cpython-39.pyc deleted file mode 100644 index c724bb3..0000000 Binary files a/__pycache__/ncl_colormap.cpython-39.pyc and /dev/null differ diff --git a/cmap_data/KMA_radar_old.rgb b/cmap_data/KMA_radar_old.rgb deleted file mode 100644 index 565ab51..0000000 --- a/cmap_data/KMA_radar_old.rgb +++ /dev/null @@ -1,64 +0,0 @@ - -rs = 25 - -1 1 1 - -0.529 0.850 1.000 \ - - -0.027 0.670 1.000 \ - - -0.000 0.466 0.701 \ - - -0.411 0.988 0.411 \ - - -0.000 0.835 0.000 \ - - -0.000 0.643 0.000 \ - - -0.000 0.501 0.000 \ - - -1.000 0.917 0.431 \ - - -1.000 0.862 0.121 \ - - -0.976 0.803 0.000 \ - - -0.878 0.725 0.000 \ - - -0.800 0.667 0.000 \ - - 0.980 0.521 0.521 \ - - 0.933 0.043 0.043 \ - - 0.835 0.000 0.000 \ - - 0.749 0.000 0.000 \ - - 0.854 0.529 1.000 \ - - 0.760 0.243 1.000 \ - - 0.572 0.000 0.894 \ - - 0.498 0.000 0.749 \ - - 0.701 0.705 0.870 \ - - 0.298 0.305 0.694 \ - - 0.000 0.011 0.564 \ - - -0.156 0.156 0.156 diff --git a/ncl_colormap.py b/ncl_colormap.py index 0fb3950..5c1ba34 100644 --- a/ncl_colormap.py +++ b/ncl_colormap.py @@ -1,96 +1,8 @@ -import os -import numpy as np -from matplotlib.colors import ListedColormap, BoundaryNorm +"""Backward-compatible shim for legacy imports. -class ColorMapMaker: - """ - Colormap 이름과 데이터 경로를 기반으로 Matplotlib의 컬러맵, - 정규화(norm), 경계(boundaries)를 관리하는 클래스입니다. +Use ``from pyncmap import ColorMapMaker`` in new code. +""" - 사용 예시: - >>> cbar = ColorMapMaker('my_cmap_r') - >>> cmap = cbar.cmap - >>> norm = cbar.norm - >>> boundaries = cbar.boundaries - >>> # 또는 한 번에 가져오기 - >>> cmap, norm, boundaries = cbar.get_all() - >>> plt.pcolormesh(data, cmap=cmap, norm=norm) - """ - def __init__(self, name: str, data_dir: str = None): - """ - Args: - name (str): 불러올 컬러맵의 이름. 이름 끝에 '_r'을 붙이면 색상 순서가 반전됩니다. - data_dir (str, optional): cmap 데이터 파일(.rgb, .bound)이 있는 - 디렉토리 경로. 지정하지 않으면 이 파일과 - 동일한 경로의 'cmap_data' 폴더를 사용합니다. - """ - self.name = name - self._reverse = name.endswith('_r') - self._base_name = name[:-2] if self._reverse else name +from pyncmap import ColorMapMaker, get_colormap, list_available_colormaps - if data_dir is None: - # 스크립트가 실행되는 위치를 기준으로 'cmap_data' 폴더 경로를 설정합니다. - cwd = os.path.dirname(os.path.abspath(__file__)) - self._data_path = os.path.join(cwd, 'cmap_data') - else: - self._data_path = data_dir - - # 결과를 캐싱하기 위한 내부 변수 - self._cmap = None - self._boundaries = None - self._norm = None - - @staticmethod - def _is_float(element: any) -> bool: - """문자열이 float으로 변환 가능한지 확인하는 정적 헬퍼 메서드""" - try: - float(element) - return True - except ValueError: - return False - - @property - def cmap(self) -> ListedColormap: - """컬러맵 객체 (ListedColormap)를 반환합니다. 첫 호출 시 파일을 읽어 생성합니다.""" - if self._cmap is None: - rgb_file = os.path.join(self._data_path, f'{self._base_name}.rgb') - with open(rgb_file, 'r') as f: - lines = f.readlines() - - li_rgb = [] - for line in lines: - # 공백으로 분리된 숫자들을 float 리스트로 변환 - colors = [float(s) for s in line.split() if self._is_float(s)] - if len(colors) == 3: - li_rgb.append(colors) - - if self._reverse: - li_rgb = list(reversed(li_rgb)) - - data = np.array(li_rgb) - # 데이터를 최대값으로 나누어 0-1 사이로 정규화 - data = data / np.max(data) - self._cmap = ListedColormap(data, name=self.name) - return self._cmap - - @property - def boundaries(self) -> list: - """경계 값 리스트를 반환합니다. 첫 호출 시 파일을 읽어 생성합니다.""" - if self._boundaries is None: - bound_file = os.path.join(self._data_path, f'{self._base_name}.bound') - with open(bound_file, 'r') as f: - # list comprehension을 사용하여 더 간결하게 작성 - self._boundaries = [float(line.strip()) for line in f] - return self._boundaries - - @property - def norm(self) -> BoundaryNorm: - """정규화 객체 (BoundaryNorm)를 반환합니다. 첫 호출 시 생성합니다.""" - if self._norm is None: - # .cmap과 .boundaries 속성을 사용하여 BoundaryNorm 객체 생성 - self._norm = BoundaryNorm(self.boundaries, self.cmap.N, clip=True) - return self._norm - - def get_all(self) -> tuple: - """컬러맵, 정규화 객체, 경계 리스트를 튜플로 한 번에 반환합니다.""" - return self.cmap, self.norm, self.boundaries +__all__ = ["ColorMapMaker", "get_colormap", "list_available_colormaps"] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0472893 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "pyncmap" +version = "0.1.0" +description = "Load NCL-style RGB colormaps in Matplotlib" +readme = "README.md" +requires-python = ">=3.9" +license = { text = "MIT" } +authors = [{ name = "pyncmap contributors" }] +dependencies = [ + "matplotlib>=3.5", + "numpy>=1.21", +] +keywords = ["ncl", "matplotlib", "colormap", "visualization"] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "License :: OSI Approved :: MIT License", + "Intended Audience :: Science/Research", + "Topic :: Scientific/Engineering :: Visualization", +] + +[project.urls] +Homepage = "https://github.com/example/pyncmap" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +pyncmap = ["cmap_data/*.rgb", "cmap_data/*.bound"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/pyncmap/__init__.py b/src/pyncmap/__init__.py new file mode 100644 index 0000000..c47c245 --- /dev/null +++ b/src/pyncmap/__init__.py @@ -0,0 +1,5 @@ +"""pyNCmap: Load NCL-style RGB colormaps for Matplotlib.""" + +from .core import ColorMapMaker, get_colormap, list_available_colormaps + +__all__ = ["ColorMapMaker", "get_colormap", "list_available_colormaps"] diff --git a/src/pyncmap/cmap_data/KMA_precip1224.bound b/src/pyncmap/cmap_data/KMA_precip1224.bound new file mode 100644 index 0000000..a74b50e --- /dev/null +++ b/src/pyncmap/cmap_data/KMA_precip1224.bound @@ -0,0 +1,32 @@ +0 +1 +2 +4 +6 +8 +10 +12 +15 +20 +25 +30 +40 +50 +60 +70 +80 +90 +100 +110 +130 +150 +200 +250 +300 +350 +400 +500 +600 +700 +800 +900 \ No newline at end of file diff --git a/src/pyncmap/cmap_data/KMA_precip1224.rgb b/src/pyncmap/cmap_data/KMA_precip1224.rgb new file mode 100644 index 0000000..fdb51b7 --- /dev/null +++ b/src/pyncmap/cmap_data/KMA_precip1224.rgb @@ -0,0 +1,34 @@ + ncolors=31 +# r g b +255 255 255 +232 232 155 +220 220 106 +209 209 57 +169 169 38 +120 120 27 +155 232 155 +106 220 106 +57 209 57 +38 169 38 +27 120 27 +155 232 230 +106 220 218 +57 209 206 +38 169 166 +27 120 118 +155 155 232 +106 106 220 +57 57 209 +38 38 169 +27 27 120 +232 155 232 +220 106 220 +209 57 209 +169 38 169 +120 27 120 +232 155 155 +220 106 106 +209 57 57 +169 38 38 +169 38 38 +40 40 40 \ No newline at end of file diff --git a/src/pyncmap/cmap_data/KMA_precip1560.bound b/src/pyncmap/cmap_data/KMA_precip1560.bound new file mode 100644 index 0000000..73a644d --- /dev/null +++ b/src/pyncmap/cmap_data/KMA_precip1560.bound @@ -0,0 +1,32 @@ +0 +0.1 +0.2 +0.4 +0.6 +0.8 +1.0 +1.5 +2.0 +3.0 +4.0 +5.0 +6.0 +7.0 +8.0 +9.0 +10 +12 +14 +16 +18 +20 +25 +30 +35 +40 +50 +60 +70 +80 +90 +100 \ No newline at end of file diff --git a/src/pyncmap/cmap_data/KMA_precip1560.rgb b/src/pyncmap/cmap_data/KMA_precip1560.rgb new file mode 100644 index 0000000..fdb51b7 --- /dev/null +++ b/src/pyncmap/cmap_data/KMA_precip1560.rgb @@ -0,0 +1,34 @@ + ncolors=31 +# r g b +255 255 255 +232 232 155 +220 220 106 +209 209 57 +169 169 38 +120 120 27 +155 232 155 +106 220 106 +57 209 57 +38 169 38 +27 120 27 +155 232 230 +106 220 218 +57 209 206 +38 169 166 +27 120 118 +155 155 232 +106 106 220 +57 57 209 +38 38 169 +27 27 120 +232 155 232 +220 106 220 +209 57 209 +169 38 169 +120 27 120 +232 155 155 +220 106 106 +209 57 57 +169 38 38 +169 38 38 +40 40 40 \ No newline at end of file diff --git a/cmap_data/KMA_radar_old.bound b/src/pyncmap/cmap_data/KMA_radar.bound similarity index 73% rename from cmap_data/KMA_radar_old.bound rename to src/pyncmap/cmap_data/KMA_radar.bound index 6731a02..f940f51 100644 --- a/cmap_data/KMA_radar_old.bound +++ b/src/pyncmap/cmap_data/KMA_radar.bound @@ -1,4 +1,5 @@ -0 +0.0 +0.01 0.1 0.5 1 @@ -19,6 +20,7 @@ 50 60 70 -80 90 -110 \ No newline at end of file +110 +150 +500 \ No newline at end of file diff --git a/src/pyncmap/cmap_data/KMA_radar.rgb b/src/pyncmap/cmap_data/KMA_radar.rgb new file mode 100644 index 0000000..fc8648a --- /dev/null +++ b/src/pyncmap/cmap_data/KMA_radar.rgb @@ -0,0 +1,25 @@ +255 255 255 +0 200 255 +0 155 245 +0 74 245 +0 255 0 +0 190 0 +0 140 0 +0 90 0 +255 255 0 +255 220 31 +249 205 0 +224 185 0 +204 170 0 +255 102 0 +255 50 0 +210 0 0 +180 0 0 +224 169 255 +201 105 255 +179 41 255 +147 0 228 +179 180 222 +76 78 177 +0 3 144 +51 51 51 \ No newline at end of file diff --git a/src/pyncmap/core.py b/src/pyncmap/core.py new file mode 100644 index 0000000..3c8372c --- /dev/null +++ b/src/pyncmap/core.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +import numpy as np +from matplotlib.colors import BoundaryNorm, ListedColormap + +DEFAULT_DATA_DIR = Path(__file__).with_name("cmap_data") + + +class ColormapFileError(FileNotFoundError): + """Raised when required colormap resource files are missing.""" + + +@dataclass +class ColorMapMaker: + """Load NCL-style RGB and boundary files and expose Matplotlib objects. + + Parameters + ---------- + name: + Colormap name. Add ``_r`` suffix to reverse color order. + data_dir: + Path to ``*.rgb`` and ``*.bound`` files. If omitted, packaged data is used. + """ + + name: str + data_dir: str | Path | None = None + + _cmap: ListedColormap | None = field(default=None, init=False, repr=False) + _boundaries: list[float] | None = field(default=None, init=False, repr=False) + _norm: BoundaryNorm | None = field(default=None, init=False, repr=False) + + def __post_init__(self) -> None: + self._reverse = self.name.endswith("_r") + self._base_name = self.name[:-2] if self._reverse else self.name + self._data_path = Path(self.data_dir) if self.data_dir is not None else DEFAULT_DATA_DIR + + @property + def cmap(self) -> ListedColormap: + if self._cmap is None: + rgb_values = self._load_rgb_values(self._rgb_file) + if self._reverse: + rgb_values = rgb_values[::-1] + + array = np.asarray(rgb_values, dtype=np.float64) + if array.size == 0: + raise ValueError(f"No valid RGB rows found in {self._rgb_file}") + + scale = 255.0 if np.max(array) > 1.0 else 1.0 + array = np.clip(array / scale, 0.0, 1.0) + self._cmap = ListedColormap(array, name=self.name) + return self._cmap + + @property + def boundaries(self) -> list[float]: + if self._boundaries is None: + path = self._bound_file + try: + with path.open("r", encoding="utf-8") as handle: + values = [float(line.strip()) for line in handle if line.strip()] + except FileNotFoundError as exc: + raise ColormapFileError( + f"Boundary file not found for '{self._base_name}': {path}" + ) from exc + + if not values: + raise ValueError(f"No boundary values found in {path}") + self._boundaries = values + return self._boundaries + + @property + def norm(self) -> BoundaryNorm: + if self._norm is None: + self._norm = BoundaryNorm(self.boundaries, self.cmap.N, clip=True) + return self._norm + + def get_all(self) -> tuple[ListedColormap, BoundaryNorm, list[float]]: + return self.cmap, self.norm, self.boundaries + + @property + def _rgb_file(self) -> Path: + path = self._data_path / f"{self._base_name}.rgb" + if not path.exists(): + raise ColormapFileError(f"RGB file not found for '{self._base_name}': {path}") + return path + + @property + def _bound_file(self) -> Path: + return self._data_path / f"{self._base_name}.bound" + + @staticmethod + def _load_rgb_values(path: Path) -> list[list[float]]: + rows: list[list[float]] = [] + try: + with path.open("r", encoding="utf-8") as handle: + for line in handle: + values = _extract_numeric_tokens(line) + if len(values) == 3: + rows.append(values) + except FileNotFoundError as exc: + raise ColormapFileError(f"RGB file not found: {path}") from exc + return rows + + +def _extract_numeric_tokens(line: str) -> list[float]: + output: list[float] = [] + for token in line.split(): + try: + output.append(float(token)) + except ValueError: + continue + return output + + +def list_available_colormaps(data_dir: str | Path | None = None) -> list[str]: + """Return available colormap names based on ``*.rgb`` files.""" + + root = Path(data_dir) if data_dir is not None else DEFAULT_DATA_DIR + return sorted(path.stem for path in root.glob("*.rgb")) + + +def get_colormap( + name: str, data_dir: str | Path | None = None +) -> tuple[ListedColormap, BoundaryNorm, list[float]]: + """Convenience function returning ``(cmap, norm, boundaries)``.""" + + return ColorMapMaker(name=name, data_dir=data_dir).get_all() diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..87b072f --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,29 @@ +from pyncmap import ColorMapMaker, get_colormap, list_available_colormaps + + +def test_list_available_colormaps_contains_seed_data(): + names = list_available_colormaps() + assert "KMA_radar" in names + assert "KMA_radar_old" not in names + + +def test_colormap_loading_and_norm(): + maker = ColorMapMaker("KMA_radar") + cmap, norm, boundaries = maker.get_all() + + assert cmap.N > 0 + assert len(boundaries) > 1 + assert norm(boundaries[0]) == 0 + + +def test_reverse_colormap(): + forward = ColorMapMaker("KMA_radar").cmap.colors + reversed_cmap = ColorMapMaker("KMA_radar_r").cmap.colors + assert (forward[0] == reversed_cmap[-1]).all() + + +def test_convenience_function(): + cmap, norm, boundaries = get_colormap("KMA_precip1224") + assert cmap.N > 0 + assert norm is not None + assert boundaries