draw_weather 0.3.0
气象地图绘制工具包
License: MIT
Requires Python: >=3.7

# 气象地图绘制工具包


## 安装
```
pip install draw_weather
```

## 发布到 PyPI

该项目支持使用 [uv](https://docs.astral.sh/uv/) 工具进行构建和发布。

### 构建包
```bash
uv build
```

### 发布到 PyPI
首先需要在 PyPI 上获取 API 令牌，然后执行：
```bash
uv publish --token <YOUR_PYPI_TOKEN> dist/*
```

### 发布到 TestPyPI（测试）
```bash
uv publish --repository testpypi --token <YOUR_TESTPYPI_TOKEN> dist/*
```

也可以使用我们提供的发布脚本：
```bash
# 发布到 PyPI
./publish.sh <YOUR_PYPI_TOKEN>

# 发布到 TestPyPI
./publish.sh <YOUR_TESTPYPI_TOKEN> test
```

## 示例

### 绘制基本地图
```python
from draw_weather import Plotter

Plotter.draw_city("西安区县").render()
```
![](./examples/pngs/base_map.png)



### 保存图片
```python
from draw_weather import Plotter

# render()中可传入图片路径
Plotter.draw_city("西安区县").render("xx.png")
```



### 地图上叠加散点图，自定义数据、颜色等。
```python
import numpy as np
from draw_weather import Plotter

colormap = "RdYlGn_r"
lons = np.array([108.2564, 108.8897, 109.0711, 109.2317, 109.3683, 108.5842])
lats = np.array([34.1633, 34.0908, 34.5447, 34.395, 34.13, 34.1356])
values = np.array([64, 64, 59, 64, 64, 23])

Plotter.draw_city("西安区县").add_scatter(lons, lats, values, colormap).render()

```
![](./examples/pngs/scatter.png)


### 地图上叠加contourf（色斑图、等值线图） 图，可传入数据、颜色等信息。
> render()如果传入图片路径，则保存。否则只调用plt.show()展示。
```python
from draw_weather import Plotter
from draw_weather.utils.colormap import ColorMap


colormap = ColorMap.get_colormap("temperature")
Plotter.draw_city("西安区县").add_contourf(lons, lats, values, colormap).render()

```
![](./examples/pngs/contourf.png)


### 地图上叠加NC文件-预览NC数据
> 预先了解nc文件，有助于图片绘制和优化 (下面例子中的soil.nc可以在example文件夹中找到)
```python
from draw_weather import Plotter

Plotter.draw_city("西安区县").add_nc("soil.nc", glance=True).render()
```
![](./examples/pngs/nc_glance.png)


### 地图上叠加NC文件
```python
from draw_weather import Plotter

Plotter.draw_city("西安区县").add_nc("soil.nc").render()
```
![](./examples/pngs/nc.png)


### 使用自定义颜色
```python
from draw_weather.utils.colormap import ColorMap
from draw_weather import Plotter

colors = [
    "#A50026",
    "#D73027",
    "#F46D43",
    "#FDAE61",
    "#FEE090",
    "#FFFFBF",
    "#E0F3F8",
    "#ABD9E9",
    "#74ADD1",
    "#4575B4",
    "#313695",
]
values = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 999]
cmap, norm = ColorMap.create_cmap_from_list(color_list=colors, value_list=values)
Plotter.draw_city("西安区县").add_nc("soil.nc", colormap=cmap, norm=norm).render()
```
![](./examples/pngs/xian_custom.png)


### 绘制乡镇图
```python
from draw_weather.utils.colormap import ColorMap
from draw_weather import Plotter

colors = [
    "#A50026",
    "#D73027",
    "#F46D43",
    "#FDAE61",
    "#FEE090",
    "#FFFFBF",
    "#E0F3F8",
    "#ABD9E9",
    "#74ADD1",
    "#4575B4",
    "#313695",
]
values = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 999]
cmap, norm = ColorMap.create_cmap_from_list(color_list=colors, value_list=values)
Plotter.draw_town("周至县").add_nc("soil.nc", colormap=cmap, norm=norm).render()
```
![](./examples/pngs/zhouzhi_custom.png)

### 地图上叠加自定义图形
> 继承BaseRenderer类，并实现plot方法即可
```python
from draw_weather import Plotter
from draw_weather.render import BaseRender


class CustomGraph(BaseRender):
    def plot(self):
        print("CustomGraph render")
Plotter.draw_city("西安区县").add_custom_graph(CustomGraph()).render()
```

###  地图上叠加自定义图形举例，以卫星遥感tif文件为例

```python
import os
import re
from typing import List, Union

import matplotlib.pyplot as plt
import numpy as np
import rasterio
from draw_weather import Plotter
from draw_weather.render import BaseRender
from draw_weather.utils.colormap import ColorMap
from matplotlib.offsetbox import (
    AnchoredOffsetbox,
    DrawingArea,
    HPacker,
    TextArea,
    VPacker,
)
from matplotlib.patches import Rectangle


def extract_dates(fname: str) -> Union[str, List[str]]:
    """
    从文件名中提取所有 8 位日期（YYYYMMDD）。
    结果：
      - 仅 0 个：返回空字符串 ''
      - 仅 1 个：返回该日期字符串
      - ≥2 个：返回日期列表（保持出现顺序）
    """
    dates = re.findall(r"\d{8}(?=\D|$)", fname)
    if not dates:
        return ""

    return "-".join(dates)


class TiffSingleCrs(BaseRender):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.tif_path = kwargs.get("tif_path")

    def read_tif(self, tif_file_path):
        # 读取TIF文件

        # clean_data = data[~np.isnan(data)]  # 移除所有NaN
        # print(np.nanmax(clean_data), np.amin(clean_data),np.unique(clean_data)[:])
        with rasterio.open(tif_file_path) as src:
            data = src.read(1)  # 读取第一个波段
            transform = src.transform
            crs = src.crs
            extent = [
                src.bounds.left,
                src.bounds.right,
                src.bounds.bottom,
                src.bounds.top,
            ]
        return data, extent

    def set_color(self):
        # 创建自定义颜色映射
        # 0为白色(背景)，1-7表示玉米长势从差到好
        colors = [
            "#FFFFFF",  # 0: 白色背景
            "#8B0000",  # 1: 深红色 - 长势很差
            "#FF4500",  # 2: 橙红色 - 长势差
            "#FFA500",  # 3: 橙色 - 长势较差
            "#FFD700",  # 4: 黄绿色 - 长势一般
            "#ADFF2F",  # 5: 浅绿色 - 长势较好
            "#32CD32",  # 6: 酸橙绿 - 长势好
            "#006400",  # 7: 深绿色 - 长势很好
        ]
        
        cmap, norm = ColorMap.create_cmap_from_list(color_list=colors, value_list=np.arange(-0.5, 8.5, 1))

        # 创建颜色映射和边界
        # cmap = ListedColormap(colors)
        # norm = BoundaryNorm(np.arange(-0.5, 8.5, 1), cmap.N)
        return colors, cmap, norm

    def analyze_points(self, data: np.ndarray) -> dict:
        # res
        level_points = {i: 0 for i in range(1, 8)}

        # 排除背景像素
        valid_data = data[data > 0]

        # 统计非零值，以及其数量。然后汇总到level_points中。
        unique_values, counts = np.unique(valid_data, return_counts=True)
        level_points.update(zip(unique_values, counts))

        if 0 in level_points:
            del level_points[0]

        for value, count in level_points.items():
            level_points[value] = f"{count / valid_data.size * 100:.2f}%"

        return level_points

    def plot_corn(self, data, extent):
        # fig, ax = plt.subplots(figsize=(16, 10))
        ax = self.ax
        ax.set_aspect("equal")

        # 绘制数据
        colors, cmap, norm = self.set_color()
        im = ax.imshow(data, extent=extent, cmap=cmap, norm=norm, interpolation="nearest")

        # 设置图形标题和标签
        # ax.set_title("玉米长势分布图", fontsize=16, fontweight="bold", pad=20)
        # ax.set_xlabel("经度°E", fontsize=12)
        # ax.set_ylabel("纬度°N", fontsize=12)

        # 添加网格
        ax.grid(True, alpha=0.3)

        # 左上角添加统计框
        level_points = self.analyze_points(data)

        # stats_text = "数据统计:\n"
        level_names = ["", "很差", "差  ", "较差", "一般", "较好", "好  ", "很好"]
        # (0，0.07)、差(0.07，0.17)、较差(0.17，0.3)、中等(0.3，0.5)、较好(0.5，0.7)、好(0.7，0.9)、很好(0.9，1.0)
        level_range = [
            "",
            "(0.00-0.07)",
            "(0.07-0.17)",
            "(0.17-0.30)",
            "(0.30-0.50)",
            "(0.50-0.70)",
            "(0.70-0.90)",
            "(0.90-1.00)",
        ]

        # for val, percentage in level_points.items():
        #     if val > 0:  # 只显示非背景数据的统计
        #         stats_text += f"等级{int(val)}({level_names[int(val)]}): {percentage}\n"
        #
        # # 在图上添加统计信息文本框
        # ax.text(
        #     0.02,
        #     0.98,
        #     stats_text,
        #     transform=ax.transAxes,
        #     verticalalignment="top",
        #     bbox=dict(boxstyle="round", facecolor="white", alpha=0.8),
        #     fontsize=10,
        # )
        # 2. 构造每行
        rows = []
        for val, pct in level_points.items():
            if val <= 0:
                continue
            color = colors[int(val)]

            # 2.1 画一个 8×8 points 的小色块
            rect_width = 25
            rect_height = 8
            da = DrawingArea(rect_width, rect_height, 0, 0)  # 8 points ≈ 3 mm
            da.add_artist(
                Rectangle(
                    (0, 0),
                    rect_width,
                    rect_height,
                    facecolor=color,
                    edgecolor="k",
                    linewidth=0.5,
                )
            )

            # 2.2 文字
            text = f"{level_names[int(val)]}: {pct}"
            # text = f"等级{int(val)}({level_names[int(val)]}){level_range[int(val)]}: {pct}"

            # txt = TextArea(text, textprops=dict(fontproperties=prop, fontsize=10))
            txt = TextArea(text, textprops=dict(fontsize=12))

            # 2.3 水平打包
            row = HPacker(children=[da, txt], align="center", pad=0, sep=4)
            rows.append(row)

        # 3. 垂直打包
        vbox = VPacker(children=rows, align="left", pad=3, sep=2)

        # 4. 放进 AnchoredOffsetbox，左上角
        anchored = AnchoredOffsetbox(
            loc="upper left",
            child=vbox,
            pad=0.5,
            frameon=True,
            bbox_to_anchor=(0.02, 0.98),
            bbox_transform=ax.transAxes,
        )
        ax.add_artist(anchored)

        self.patch_axis_format()
        plt.tight_layout()

        # 设置title
        time_str = extract_dates(os.path.basename(self.tif_path))
        if "-" in time_str:
            title = f"卫星遥感-西安市农作物长势对比 {time_str}"
        else:
            title = f"卫星遥感-西安市农作物长势 {time_str}"

        self.patch_title(title)

        # plt.show()

    def plot(self):
        tdata, textent = self.read_tif(self.tif_path)
        self.plot_corn(tdata, textent)
        # print("TiffSingleCrs render"


def tif2png(source_tif_path, target_png_path):
    Plotter.draw_city("西安区县").add_custom_graph(TiffSingleCrs, tif_path=source_tif_path).render(target_png_path)


def test_tif():
    tif_path = "/root/Deploy/tif2png/src/tif_2_png/CHN-SA-XIN_20240703_MOD09A1_500M_corn_Growth_rank.tif"
    Plotter.draw_city("西安区县").add_custom_graph(TiffSingleCrs, tif_path=tif_path).render("test.png")


if __name__ == "__main__":
    test_tif()

```
![](./examples/pngs/growth.png)