开发需求中需要实现一个压力分布的热力图,我在WPF中使用的图表组件是ScottPlot,想实现类似于等高线那种的热力分布图,查遍了所有的文档,都没能找到一个实现方案,于是想从python或者web的图表库中入手,使用ECharts应该也可以实现,但是需要在程序中嵌入一个Web Browser,于是想先研究一下使用python的方法,查阅了很久的资料后,发现了一个github上一个绝配的demo,这里也非常感谢这位大佬,github链接放在下面:
chickenservice/matplotlib.net: A wrapper to embed interactive matplotlib in WPF applications (github.com)

但是呢,在将这个demo集成到我的项目里时,也遇到了很多的问题,这里记录一下,可供参考。

首先,我直接将demo的代码clone到本地执行,进行测试,这里需要注意的是,作者在编写这个wrapper的时候,在C#中以及对fig, ax = plt.subplots()进行了封装,在我们的python代码中,不需要出现figax,可以直接使用,但是需要以闭包的形式作为函数参数才传递到需要使用fig和ax的地方。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation

from scipy.signal import convolve2d



#fig.set_figwidth(19.2)
#fig.set_figheight(10.8)


def init(n, ax):
import numpy as np
state = np.random.choice(a=[False, True], size=(n, n))*1
img = ax.imshow(state, interpolation='nearest', cmap='gray')

con = np.array([
[1, 1, 1],
[1, 0, 1],
[1, 1, 1]])

def game_of_life(state):
from scipy.signal import convolve2d
nghbd = convolve2d(state, con, mode='same')
state = (state*(nghbd == 2) + (nghbd == 3))
return state

def _update(frame):
nonlocal state
state = game_of_life(state*1)
img.set_array(state)
return img,

return state, _update

s, update = init(100, ax)

ani = animation.FuncAnimation(
fig=fig, func=update, frames=360, interval=30, blit=False, repeat=False)

fig.canvas.draw()

在作者的这个示例代码中,我们可以看到是没有对figax进行定义的,而是直接作为参数传递进入了init函数和FuncAnimation中,且这里呢需要将_update函数作为FuncAnimation的参数进行传递,所以需要以闭包的形式获得_update。总之,我们首先需要将我们自己的代码修改层类似demo中的代码的这种形式,才能够在作者的程序中运行python代码,当然这里条件是我也需要使用FuncAnimation这个功能,如果不使用这个功能可能不需要这么麻烦。

其次,作者使用MplPanel.csNetMplAdapter.cs封装了自定义组件MplPanel这样我们可以直接在我们的xmal文件中,去使用这个组件,并且可以直接通过这个组件对象来进行python代码的调用,然后将可视化的图表直接显示在组件中。

下面讲一下将demo中的代码迁移到我自己的项目中,首先将所有的代码都先迁移到自己的项目中,并全部修改namespace,由于作者使用的是.net7框架,如果是使用.net framework框架的可能需要修改一些代码,比如其中的using的使用,可以使用ChatGPT去问一下,比较好解决。其次有两个文件,一个是backend_wpfagg.py,一个是appsettings.json,这两个文件前者是matplotlib的一个wpf的backend,后者是指定python路径的配置文件,这两个文件只放到项目目录下是没用的,需要放到编译出的可执行文件的目录下,可以自行手动复制进去,也可以配置一下csproj文件,在编译的时候自动复制进去。这里可以参考demo的csproj文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net7.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<RootNamespace>Matplotlib.Net</RootNamespace>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0-preview.3.24172.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0-preview.3.24172.9" />
<PackageReference Include="pythonnet" Version="3.0.3" />
</ItemGroup>

<ItemGroup>
<None Update="backend_wpfagg.py">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>

</Project>

以及backend_wpfagg.py中的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
"""
A work-in-progress backend for .NET WPF and potentially other .NET GUI frameworks in the future.
::

import matplotlib
matplotlib.use("template")

Copy this file to a directory where Python can import it (by adding the
directory to your ``sys.path`` or by packaging it as a normal Python package);
e.g. ``import backend_wpfagg`` you can then select it using ::

import matplotlib
matplotlib.use("module://backend_wpfagg")

"""

from matplotlib import backend_bases
from matplotlib.backend_bases import (
FigureManagerBase, MouseEvent, MouseButton, TimerBase)
from matplotlib.backends.backend_agg import FigureCanvasAgg

from System.Windows.Threading import DispatcherTimer, DispatcherPriority
from System import TimeSpan, EventHandler

from Matplotlib.Net import NetMplAdapter


class TimerWpf(TimerBase):
def __init__(self, *args, **kwargs):
self._timer = DispatcherTimer(DispatcherPriority.Render)
self._timer.Tick += EventHandler(self._on_tick)
super().__init__(*args, **kwargs)

def _timer_start(self):
self._timer.Stop()
self._timer.Start()

def _timer_stop(self):
self._timer.Stop()

def _timer_set_interval(self):
self._timer.Interval = TimeSpan.FromMilliseconds(self._interval)

def _on_tick(self, a, b):
super()._on_timer()


class NavigationToolbar2WpfAgg(backend_bases.NavigationToolbar2):
toolitems = [
(text, tooltip_text, image_file, name_of_method)
for text, tooltip_text, image_file, name_of_method
in (*backend_bases.NavigationToolbar2.toolitems,
('Download', 'Download plot', 'filesave', 'download'))
]

def __init__(self, canvas):
super().__init__(canvas)


class FigureManagerWpfAgg(FigureManagerBase):
_toolbar2_class = NavigationToolbar2WpfAgg

def __init__(self, canvas, num):
super().__init__(canvas, num)
self._dotnet_manager = NetMplAdapter(self, canvas)
self.toolbar.pan()

def handle_resize(self, w, h):
fig = self.canvas.figure
fig.set_size_inches(w/fig.dpi, h/fig.dpi, forward=False)
from matplotlib.backend_bases import ResizeEvent
self.resize(*fig.bbox.size)
ResizeEvent('resize_event', self.canvas)._process()
self.show()

def show(self):
if self._dotnet_manager is not None:
self.canvas.draw_idle()


class FigureCanvasWpf(FigureCanvasAgg):
manager_class = FigureManagerWpfAgg
_timer_cls = TimerWpf

def draw(self):
super().draw()
buf = self.buffer_rgba()
w, h = self.get_width_height()
self.manager._dotnet_manager.Draw(buf, w, h)

def handle_wheel(self, pos, delta):
x, y = pos
actual_y = self.figure.bbox.height - y
MouseEvent('scroll_event', self, x, actual_y, step=delta,
)._process()

def handle_mouse_move(self, pos):
x, y = pos
actual_y = self.figure.bbox.height - y
MouseEvent("motion_notify_event", self, x, actual_y)._process()

def handle_mouse_up(self, pos, button):
x, y = pos
actual_y = self.figure.bbox.height - y
MouseEvent("button_release_event", self, x, actual_y, MouseButton.LEFT if button == "left" else MouseButton.RIGHT)._process()

def handle_mouse_down(self, pos, button):
x, y = pos
actual_y = self.figure.bbox.height - y
MouseEvent("button_press_event", self, x, actual_y, MouseButton.LEFT if button == "left" else MouseButton.RIGHT)._process()


FigureCanvas = FigureCanvasWpf
FigureManager = FigureManagerWpfAgg

appsettings.json中的代码为,这里是我自己的,仅供参考:

1
2
3
4
5
6
{
"Python": {
"PythonHome": "C:\\Users\\jym\\miniconda3\\envs\\dpl",
"PythonVersion": "38"
}
}

这里指定的python路径不能是python embed的,目前我也还不知道为什么,如果使用python embed的环境,会出现软件运行就结束的情况,没有任何报错,使用try-catch也捕获不到任何的异常,这里我使用的是Miniconda创建的虚拟环境,而且注意,python的版本要在pythonnet这个库的支持范围内。

然后重点来了,这里在你自己的项目中直接运行可能会出现报错找不到Matplotlib这个库,但是请注意,这里的Matploblib非彼matplotlib,这里的Matploblibbackend_wpfagg.py中的这行代码:

1
from Matplotlib.Net import NetMplAdapter

所以在这里,务必将这里的Matplotlib.Net修改成你自己的项目名称,否则这个错误根本找不到解决方法。