added plots to readme

This commit is contained in:
weygoldt 2023-04-26 19:41:16 +02:00
parent 951c22876b
commit 78cbd0498b
No known key found for this signature in database
9 changed files with 61040 additions and 150 deletions

View File

@ -1,49 +1,28 @@
<!-- Improved compatibility of back to top link: See: https://github.com/othneildrew/Best-README-Template/pull/73 -->
<a name="readme-top"></a>
<!-- PROJECT LOGO -->
<br />
<div align="center">
<a href="https://github.com/github_username/repo_name">
<img src="assets/logo.png" alt="Logo" width="150" height="100">
</a>
<h3 align="center">chirpdetector</h3>
<p align="center">
An algorithm to detect transient communication signals of weakly electric fish on multielectrode recordings.
<br />
<a href="https://github.com/github_username/repo_name"><strong>Explore the docs »</strong></a>
<br />
<br />
<a href="https://github.com/github_username/repo_name">View Demo</a>
·
<a href="https://github.com/github_username/repo_name/issues">Report Bug</a>
·
<a href="https://github.com/github_username/repo_name/issues">Request Feature</a>
</p>
</div>
<!-- TABLE OF CONTENTS -->
<details>
<summary>Table of Contents</summary>
<ol>
<li><a href="#about-the-project">About The Project</a></li>
<li><a href="#the-approach">Getting Started</a></li>
</ol>
</details>
## About The Project
Chirps are transient communication singals of many wave-type electric fish. Because they are so fast, detecting them when the recorded signal includes multiple individuals is hard. But to understand if, and what kind of information they transmit in a natural setting, analyzing chirps in multiple freely interacting individual is nessecary. This repository documents an approach to detect these signals on electrode grid recordings with many freely behaving individuals.
# Chirpdetector
The majority of the code and its tests were part of a lab rotation with the [Neuroethology](https://github.com/bendalab) at the University of Tuebingen. It also contains a [poster](poster_printed/main.pdf) and a more thorough [lab protocol](protocol/main.pdf).
Chirps are transient communication singals of many wave-type electric fish. Because they are so fast, detecting them when the recorded signal includes multiple individuals is hard. But to understand if, and what kind of information they transmit in a natural setting, analyzing chirps in multiple freely interacting individual is nessecary. This repository documents an approach to detect these signals on electrode grid recordings with many freely behaving individuals by extracting EOD parameters that change during a chirp and detecting anomalies on them.
<p align="right">(<a href="#readme-top">back to top</a>)</p>
The majority of the code and its tests were part of a lab rotation with the [Neuroethology](https://github.com/bendalab) at the University of Tuebingen. It also contains a [poster](poster_printed/main.pdf) and a more thorough [lab protocol](protocol/main.pdf).
## The Approach
To detect chirps, we extract some features of the raw signal using frequency traces that were computed beforehand using the [wavetracker](https://github.com/tillraab/wavetracker). For a frequency band of a single fish, we filter the signal using a bandpass filter, extract the instantaneous frequency, the envelelope and the envelope of a frequency band slightly above the fundamental frequency of the fish. We then transform those features using various filters and detect the peaks on them. Peaks on all three features are chirps. We always use the strongest electrode relative to the fish of interest. By that, we include the spatial demensions to increase our detection performance, if fish are spaced sufficiently apart. The full algorithm is thoroughly explained in the lab protocol. All parameters can be tuned using a `yaml` config file. While this approach excels in assigning detected chirps to the currect fish, the actial chirp detection is not that reliable. If peak detection thresholds are set for each feature manually, the detector can become quite reliable. But if features are normalized to generalize the detector, noise often introduces false detections, especially during amplitude breakdowns.
To detect chirps, we extract some features of the raw signal using frequency traces that were computed beforehand using the [wavetracker](https://github.com/tillraab/wavetracker). For a frequency band of a single fish, we filter the signal using a bandpass filter, extract the instantaneous frequency, the envelelope and the envelope of a frequency band slightly above the fundamental frequency of the fish. We then transform those features using various filters and detect the peaks on them. Peaks on all three features are chirps. In an ideal world, the result looks like this:
![chirps](assets/good_.svg)
We always use the strongest electrode relative to the fish of interest. By that, we include the spatial demensions to increase our detection performance, if fish are spaced sufficiently apart. The full algorithm is thoroughly explained in the lab protocol. All parameters can be tuned using a `yaml` config file. While this approach excels in assigning detected chirps to the currect fish, the actial chirp detection is not that reliable. If peak detection thresholds are set for each feature manually, the detector can become quite reliable but performance will be sensible to changes in the recording setup. But if features are normalized to generalize the detector, noise often introduces false detections, especially during amplitude breakdowns. Below is an example of a recording with false detections:
![chirps](assets/bad_.svg)
## Open questions
One of the three features we exract to detect peaks is the instantaneous frequency of the baseline EOD of a single fish. To do that, we apply a narrow bandpass filter (+- 5 Hz) around the fundamental frequency in a single 5 second window. If when the fish chirps, its real frequency should increase beyond the filter cutoff frequencies and we should see a slight increase in the instantaneous frequency. But sometimes, the frequency decreases or does not change at all.
To understand this, we simulated chirps while changing some parameters of the chirp, notably the height, width, kurtosis, contrast and EOD phase in which the chirp is produced. What we find, is that all parameters that change the integral of the chirp frequency evolution (i.e. the height, width, and kurtosis) occasionally result in negative instantaneous frequencies in the narrow filtered EOD. Specifically, if the integral increases, the instantaneous frequency increases as well, until an integer + 0.57 is reached. At this point, the frequency flips around the x axis and gradually becomes positive again. This is illustrated in the figure below for the chirp width. The integral of the zero-shifted instantaneous frequency is indicated as the title of each subplot. 1.57 would be $\pi/2$ but how this relates to the observed pattern is not clear yet. What becomes clear from the waveform below is that the point at which the EOD flips is the point where the amplitude of just the filtered signal breaks down to 0.
![chirps](assets/width.svg)
To explore the other parameters, there is a jupyter notebook in the `notebooks` [here](chirp_instantaneous_freq/chirp_exploration.ipynb).
<!-- # Chirp detection - GP2023 -->
<!-- ## Git-Repository and commands -->
@ -108,39 +87,4 @@ To detect chirps, we extract some features of the raw signal using frequency tra
<!-- git checkout master -->
<!-- git merge <branch> -->
<!-- git push origin master -->
<!-- ``` -->
<!-- MARKDOWN LINKS & IMAGES -->
<!-- https://www.markdownguide.org/basic-syntax/#reference-style-links -->
[contributors-shield]: https://img.shields.io/github/contributors/github_username/repo_name.svg?style=for-the-badge
[contributors-url]: https://github.com/github_username/repo_name/graphs/contributors
[forks-shield]: https://img.shields.io/github/forks/github_username/repo_name.svg?style=for-the-badge
[forks-url]: https://github.com/github_username/repo_name/network/members
[stars-shield]: https://img.shields.io/github/stars/github_username/repo_name.svg?style=for-the-badge
[stars-url]: https://github.com/github_username/repo_name/stargazers
[issues-shield]: https://img.shields.io/github/issues/github_username/repo_name.svg?style=for-the-badge
[issues-url]: https://github.com/github_username/repo_name/issues
[license-shield]: https://img.shields.io/github/license/github_username/repo_name.svg?style=for-the-badge
[license-url]: https://github.com/github_username/repo_name/blob/master/LICENSE.txt
[linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=for-the-badge&logo=linkedin&colorB=555
[linkedin-url]: https://linkedin.com/in/linkedin_username
[product-screenshot]: images/screenshot.png
[Next.js]: https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white
[Next-url]: https://nextjs.org/
[React.js]: https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB
[React-url]: https://reactjs.org/
[Vue.js]: https://img.shields.io/badge/Vue.js-35495E?style=for-the-badge&logo=vuedotjs&logoColor=4FC08D
[Vue-url]: https://vuejs.org/
[Angular.io]: https://img.shields.io/badge/Angular-DD0031?style=for-the-badge&logo=angular&logoColor=white
[Angular-url]: https://angular.io/
[Svelte.dev]: https://img.shields.io/badge/Svelte-4A4A55?style=for-the-badge&logo=svelte&logoColor=FF3E00
[Svelte-url]: https://svelte.dev/
[Laravel.com]: https://img.shields.io/badge/Laravel-FF2D20?style=for-the-badge&logo=laravel&logoColor=white
[Laravel-url]: https://laravel.com
[Bootstrap.com]: https://img.shields.io/badge/Bootstrap-563D7C?style=for-the-badge&logo=bootstrap&logoColor=white
[Bootstrap-url]: https://getbootstrap.com
[JQuery.com]: https://img.shields.io/badge/jQuery-0769AD?style=for-the-badge&logo=jquery&logoColor=white
[JQuery-url]: https://jquery.com
<!-- ``` -->

14906
assets/bad_.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 448 KiB

14924
assets/chirpsize.svg Normal file

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 371 KiB

15704
assets/good_.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 466 KiB

14910
assets/width.svg Normal file

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 370 KiB

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,411 @@
import cmocean as cmo
import matplotlib.pyplot as plt
import numpy as np
from cycler import cycler
from matplotlib.colors import ListedColormap
def PlotStyle() -> None:
class style:
# lightcmap = cmocean.tools.lighten(cmocean.cm.haline, 0.8)
# units
cm = 1 / 2.54
mm = 1 / 25.4
# colors
black = "#111116"
white = "#e0e4f7"
gray = "#6c6e7d"
blue = "#89b4fa"
sapphire = "#74c7ec"
sky = "#89dceb"
teal = "#94e2d5"
green = "#a6e3a1"
yellow = "#f9d67f"
orange = "#faa472"
maroon = "#eb8486"
red = "#e0e4f7"
purple = "#d89bf7"
pink = "#f59edb"
lavender = "#b4befe"
gblue1 = "#f37588"
gblue2 = "#faa472"
gblue3 = "#f9d67f"
g = "#f3626c"
@classmethod
def lims(cls, track1, track2):
"""Helper function to get frequency y axis limits from two
fundamental frequency tracks.
Args:
track1 (array): First track
track2 (array): Second track
start (int): Index for first value to be plotted
stop (int): Index for second value to be plotted
padding (int): Padding for the upper and lower limit
Returns:
lower (float): lower limit
upper (float): upper limit
"""
allfunds_tmp = (
np.concatenate(
[
track1,
track2,
]
)
.ravel()
.tolist()
)
lower = np.min(allfunds_tmp)
upper = np.max(allfunds_tmp)
return lower, upper
@classmethod
def circled_annotation(cls, text, axis, xpos, ypos, padding=0.25):
axis.text(
xpos,
ypos,
text,
ha="center",
va="center",
zorder=1000,
bbox=dict(
boxstyle=f"circle, pad={padding}",
fc="white",
ec="black",
lw=1,
),
)
@classmethod
def fade_cmap(cls, cmap):
my_cmap = cmap(np.arange(cmap.N))
my_cmap[:, -1] = np.linspace(0, 1, cmap.N)
my_cmap = ListedColormap(my_cmap)
return my_cmap
@classmethod
def hide_ax(cls, ax):
ax.xaxis.set_visible(False)
plt.setp(ax.spines.values(), visible=False)
ax.tick_params(left=False, labelleft=False)
ax.patch.set_visible(False)
@classmethod
def hide_xax(cls, ax):
ax.xaxis.set_visible(False)
ax.spines["bottom"].set_visible(False)
@classmethod
def hide_yax(cls, ax):
ax.yaxis.set_visible(False)
ax.spines["left"].set_visible(False)
@classmethod
def set_boxplot_color(cls, bp, color):
plt.setp(bp["boxes"], color=color)
plt.setp(bp["whiskers"], color=white)
plt.setp(bp["caps"], color=white)
plt.setp(bp["medians"], color=black)
@classmethod
def label_subplots(cls, labels, axes, fig):
for axis, label in zip(axes, labels):
X = axis.get_position().x0
Y = axis.get_position().y1
fig.text(X, Y, label, weight="bold")
@classmethod
def letter_subplots(
cls, axes=None, letters=None, xoffset=-0.1, yoffset=1.0, **kwargs
):
"""Add letters to the corners of subplots (panels). By default each axis is
given an uppercase bold letter label placed in the upper-left corner.
Args
axes : list of pyplot ax objects. default plt.gcf().axes.
letters : list of strings to use as labels, default ["A", "B", "C", ...]
xoffset, yoffset : positions of each label relative to plot frame
(default -0.1,1.0 = upper left margin). Can also be a list of
offsets, in which case it should be the same length as the number of
axes.
Other keyword arguments will be passed to annotate() when panel letters
are added.
Returns:
list of strings for each label added to the axes
Examples:
Defaults:
>>> fig, axes = plt.subplots(1,3)
>>> letter_subplots() # boldfaced A, B, C
Common labeling schemes inferred from the first letter:
>>> fig, axes = plt.subplots(1,4)
# panels labeled (a), (b), (c), (d)
>>> letter_subplots(letters='(a)')
Fully custom lettering:
>>> fig, axes = plt.subplots(2,1)
>>> letter_subplots(axes, letters=['(a.1)', '(b.2)'], fontweight='normal')
Per-axis offsets:
>>> fig, axes = plt.subplots(1,2)
>>> letter_subplots(axes, xoffset=[-0.1, -0.15])
Matrix of axes:
>>> fig, axes = plt.subplots(2,2, sharex=True, sharey=True)
# fig.axes is a list when axes is a 2x2 matrix
>>> letter_subplots(fig.axes)
"""
# get axes:
if axes is None:
axes = plt.gcf().axes
# handle single axes:
try:
iter(axes)
except TypeError:
axes = [axes]
# set up letter defaults (and corresponding fontweight):
fontweight = "bold"
ulets = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ"[: len(axes)])
llets = list("abcdefghijklmnopqrstuvwxyz"[: len(axes)])
if letters is None or letters == "A":
letters = ulets
elif letters == "(a)":
letters = ["({})".format(lett) for lett in llets]
fontweight = "normal"
elif letters == "(A)":
letters = ["({})".format(lett) for lett in ulets]
fontweight = "normal"
elif letters in ("lower", "lowercase", "a"):
letters = llets
# make sure there are x and y offsets for each ax in axes:
if isinstance(xoffset, (int, float)):
xoffset = [xoffset] * len(axes)
else:
assert len(xoffset) == len(axes)
if isinstance(yoffset, (int, float)):
yoffset = [yoffset] * len(axes)
else:
assert len(yoffset) == len(axes)
# defaults for annotate (kwargs is second so it can overwrite these defaults):
my_defaults = dict(
fontweight=fontweight,
fontsize="large",
ha="center",
va="center",
xycoords="axes fraction",
annotation_clip=False,
)
kwargs = dict(list(my_defaults.items()) + list(kwargs.items()))
list_txts = []
for ax, lbl, xoff, yoff in zip(axes, letters, xoffset, yoffset):
t = ax.annotate(lbl, xy=(xoff, yoff), **kwargs)
list_txts.append(t)
return list_txts
pass
# rcparams text setup
SMALL_SIZE = 12
MEDIUM_SIZE = 14
BIGGER_SIZE = 16
black = "#111116"
white = "#e0e4f7"
gray = "#6c6e7d"
dark_gray = "#2a2a32"
# rcparams
plt.rc("font", size=MEDIUM_SIZE) # controls default text sizes
plt.rc("axes", titlesize=MEDIUM_SIZE) # fontsize of the axes title
plt.rc("axes", labelsize=MEDIUM_SIZE) # fontsize of the x and y labels
plt.rc("xtick", labelsize=SMALL_SIZE) # fontsize of the tick labels
plt.rc("ytick", labelsize=SMALL_SIZE) # fontsize of the tick labels
plt.rc("legend", fontsize=SMALL_SIZE) # legend fontsize
plt.rc("figure", titlesize=BIGGER_SIZE) # fontsize of the figure title
plt.rcParams["image.cmap"] = "cmo.thermal"
plt.rcParams["axes.xmargin"] = 0.05
plt.rcParams["axes.ymargin"] = 0.1
plt.rcParams["axes.titlelocation"] = "left"
plt.rcParams["axes.titlesize"] = BIGGER_SIZE
# plt.rcParams["axes.titlepad"] = -10
plt.rcParams["legend.frameon"] = False
plt.rcParams["legend.loc"] = "best"
plt.rcParams["legend.borderpad"] = 0.4
plt.rcParams["legend.facecolor"] = black
plt.rcParams["legend.edgecolor"] = black
plt.rcParams["legend.framealpha"] = 0.7
plt.rcParams["legend.borderaxespad"] = 0.5
plt.rcParams["legend.fancybox"] = False
# # specify the custom font to use
# plt.rcParams["font.family"] = "sans-serif"
# plt.rcParams["font.sans-serif"] = "Helvetica Now Text"
# dark mode modifications
plt.rcParams["boxplot.flierprops.color"] = white
plt.rcParams["boxplot.flierprops.markeredgecolor"] = gray
plt.rcParams["boxplot.boxprops.color"] = gray
plt.rcParams["boxplot.whiskerprops.color"] = gray
plt.rcParams["boxplot.capprops.color"] = gray
plt.rcParams["boxplot.medianprops.color"] = black
plt.rcParams["text.color"] = white
plt.rcParams["axes.facecolor"] = black # axes background color
plt.rcParams["axes.edgecolor"] = white # axes edge color
# plt.rcParams["axes.grid"] = True # display grid or not
# plt.rcParams["axes.grid.axis"] = "y" # which axis the grid is applied to
plt.rcParams["axes.labelcolor"] = white
plt.rcParams["axes.axisbelow"] = True # draw axis gridlines and ticks:
plt.rcParams["axes.spines.left"] = True # display axis spines
plt.rcParams["axes.spines.bottom"] = True
plt.rcParams["axes.spines.top"] = False
plt.rcParams["axes.spines.right"] = False
plt.rcParams["axes.prop_cycle"] = cycler(
"color",
[
"#b4befe",
"#89b4fa",
"#74c7ec",
"#89dceb",
"#94e2d5",
"#a6e3a1",
"#f9e2af",
"#fab387",
"#eba0ac",
"#f38ba8",
"#cba6f7",
"#f5c2e7",
],
)
plt.rcParams["xtick.color"] = white # color of the ticks
plt.rcParams["ytick.color"] = white # color of the ticks
plt.rcParams["grid.color"] = white # grid color
plt.rcParams["figure.facecolor"] = black # figure face color
plt.rcParams["figure.edgecolor"] = black # figure edge color
plt.rcParams["savefig.facecolor"] = black # figure face color when saving
return style
if __name__ == "__main__":
s = PlotStyle()
import matplotlib.cbook as cbook
import matplotlib.cm as cm
import matplotlib.pyplot as plt
from matplotlib.patches import PathPatch
from matplotlib.path import Path
# Fixing random state for reproducibility
np.random.seed(19680801)
delta = 0.025
x = y = np.arange(-3.0, 3.0, delta)
X, Y = np.meshgrid(x, y)
Z1 = np.exp(-(X**2) - Y**2)
Z2 = np.exp(-((X - 1) ** 2) - (Y - 1) ** 2)
Z = (Z1 - Z2) * 2
fig1, ax = plt.subplots()
im = ax.imshow(
Z,
interpolation="bilinear",
cmap=cm.RdYlGn,
origin="lower",
extent=[-3, 3, -3, 3],
vmax=abs(Z).max(),
vmin=-abs(Z).max(),
)
plt.show()
fig, axs = plt.subplots(nrows=1, ncols=2, figsize=(9, 4))
# Fixing random state for reproducibility
np.random.seed(19680801)
# generate some random test data
all_data = [np.random.normal(0, std, 100) for std in range(6, 10)]
# plot violin plot
axs[0].violinplot(all_data, showmeans=False, showmedians=True)
axs[0].set_title("Violin plot")
# plot box plot
axs[1].boxplot(all_data)
axs[1].set_title("Box plot")
# adding horizontal grid lines
for ax in axs:
ax.yaxis.grid(True)
ax.set_xticks(
[y + 1 for y in range(len(all_data))],
labels=["x1", "x2", "x3", "x4"],
)
ax.set_xlabel("Four separate samples")
ax.set_ylabel("Observed values")
plt.show()
# Fixing random state for reproducibility
np.random.seed(19680801)
# Compute pie slices
N = 20
theta = np.linspace(0.0, 2 * np.pi, N, endpoint=False)
radii = 10 * np.random.rand(N)
width = np.pi / 4 * np.random.rand(N)
colors = cmo.cm.haline(radii / 10.0)
ax = plt.subplot(projection="polar")
ax.bar(theta, radii, width=width, bottom=0.0, color=colors, alpha=0.5)
plt.show()
methods = [
None,
"none",
"nearest",
"bilinear",
"bicubic",
"spline16",
"spline36",
"hanning",
"hamming",
"hermite",
"kaiser",
"quadric",
"catrom",
"gaussian",
"bessel",
"mitchell",
"sinc",
"lanczos",
]
# Fixing random state for reproducibility
np.random.seed(19680801)
grid = np.random.rand(4, 4)
fig, axs = plt.subplots(
nrows=3,
ncols=6,
figsize=(9, 6),
subplot_kw={"xticks": [], "yticks": []},
)
for ax, interp_method in zip(axs.flat, methods):
ax.imshow(grid, interpolation=interp_method)
ax.set_title(str(interp_method))
plt.tight_layout()
plt.show()

View File

@ -726,8 +726,8 @@ def chirpdetection(datapath: str, plot: str, debug: str = "false") -> None:
raw_time = np.arange(data.raw.shape[0]) / data.raw_rate
# good chirp times for data: 2022-06-02-10_00
window_start_index = (3 * 60 * 60 + 6 * 60 + 43.5) * data.raw_rate
window_duration_index = 60 * data.raw_rate
# window_start_index = (3 * 60 * 60 + 6 * 60 + 43.5) * data.raw_rate
# window_duration_index = 60 * data.raw_rate
# t0 = 0
# dt = data.raw.shape[0]

View File

@ -9,3 +9,78 @@ PyYAML==6.0
scipy==1.10.1
scp==0.14.5
tqdm==4.65.0
asttokens==2.2.1
astunparse==1.6.3
audioio==0.10.0
backcall==0.2.0
bcrypt==4.0.1
cffi==1.15.1
cmocean==3.0.3
comm==0.1.3
contourpy==1.0.7
cryptography==40.0.2
cycler==0.11.0
debugpy==1.6.7
decorator==5.1.1
execnb==0.1.5
executing==1.2.0
fastcore==1.5.29
fonttools==4.39.3
ghapi==1.0.3
ipykernel==6.22.0
ipython==8.12.0
jedi==0.18.2
joblib==1.2.0
jupyter_client==8.2.0
jupyter_core==5.3.0
kiwisolver==1.4.4
matplotlib==3.7.1
matplotlib-inline==0.1.6
nbdev==2.3.12
nest-asyncio==1.5.6
numpy==1.24.2
packaging==23.1
pandas==2.0.0
paramiko==3.1.0
parso==0.8.3
pexpect==4.8.0
pickleshare==0.7.5
Pillow==9.5.0
platformdirs==3.3.0
prompt-toolkit==3.0.38
psutil==5.9.5
ptyprocess==0.7.0
pure-eval==0.2.2
pycparser==2.21
Pygments==2.15.1
PyNaCl==1.5.0
pyparsing==3.0.9
PyQt5==5.15.9
PyQt5-Qt5==5.15.2
PyQt5-sip==12.12.1
PyQt6==6.5.0
PyQt6-Qt6==6.5.0
PyQt6-sip==13.5.1
PySide2==5.13.2
PySide6==6.5.0
PySide6-Addons==6.5.0
PySide6-Essentials==6.5.0
python-dateutil==2.8.2
pytz==2023.3
PyYAML==6.0
pyzmq==25.0.2
scikit-learn==1.2.2
scipy==1.10.1
scp==0.14.5
shiboken2==5.13.2
shiboken6==6.5.0
six==1.16.0
stack-data==0.6.2
threadpoolctl==3.1.0
thunderfish==1.9.10
tornado==6.3.1
tqdm==4.65.0
traitlets==5.9.0
tzdata==2023.3
watchdog==3.0.0
wcwidth==0.2.6