densitty 0.8.2__py3-none-any.whl → 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- densitty/axis.py +192 -55
- densitty/binning.py +134 -63
- densitty/detect.py +71 -11
- densitty/plot.py +15 -7
- densitty/smoothing.py +317 -0
- densitty/truecolor.py +2 -1
- densitty/util.py +125 -123
- densitty/util.pyi +37 -0
- {densitty-0.8.2.dist-info → densitty-0.9.0.dist-info}/METADATA +6 -3
- densitty-0.9.0.dist-info/RECORD +17 -0
- densitty-0.8.2.dist-info/RECORD +0 -15
- {densitty-0.8.2.dist-info → densitty-0.9.0.dist-info}/WHEEL +0 -0
- {densitty-0.8.2.dist-info → densitty-0.9.0.dist-info}/licenses/LICENSE +0 -0
- {densitty-0.8.2.dist-info → densitty-0.9.0.dist-info}/top_level.txt +0 -0
densitty/detect.py
CHANGED
|
@@ -9,14 +9,15 @@ from types import MappingProxyType
|
|
|
9
9
|
from typing import Any, Callable, Optional, Sequence
|
|
10
10
|
import time
|
|
11
11
|
|
|
12
|
-
from . import ansi, ascii_art, binning, lineart, truecolor
|
|
12
|
+
from . import ansi, ascii_art, binning, lineart, smoothing, truecolor
|
|
13
13
|
from . import plot as plotmodule
|
|
14
14
|
from .util import FloatLike, ValueRange
|
|
15
15
|
|
|
16
16
|
if sys.platform == "win32":
|
|
17
17
|
# pylint: disable=import-error
|
|
18
18
|
import ctypes
|
|
19
|
-
|
|
19
|
+
|
|
20
|
+
kernel32 = ctypes.windll.kernel32
|
|
20
21
|
else:
|
|
21
22
|
# All other platforms should have TERMIOS available
|
|
22
23
|
import fcntl
|
|
@@ -86,10 +87,10 @@ if sys.platform == "win32":
|
|
|
86
87
|
"""Windows-based wrapper to avoid control code output to stdout"""
|
|
87
88
|
prev_stdin_mode = ctypes.wintypes.DWORD(0)
|
|
88
89
|
prev_stdout_mode = ctypes.wintypes.DWORD(0)
|
|
89
|
-
GetConsoleMode(GetStdHandle(-10), ctypes.byref(prev_stdin_mode))
|
|
90
|
-
SetConsoleMode(GetStdHandle(-10), 0)
|
|
91
|
-
GetConsoleMode(GetStdHandle(-11), ctypes.byref(prev_stdout_mode))
|
|
92
|
-
SetConsoleMode(GetStdHandle(-11), 7)
|
|
90
|
+
kernel32.GetConsoleMode(kernel32.GetStdHandle(-10), ctypes.byref(prev_stdin_mode))
|
|
91
|
+
kernel32.SetConsoleMode(kernel32.GetStdHandle(-10), 0)
|
|
92
|
+
kernel32.GetConsoleMode(kernel32.GetStdHandle(-11), ctypes.byref(prev_stdout_mode))
|
|
93
|
+
kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
|
|
93
94
|
|
|
94
95
|
# On Windows, don't try to be non-blocking, just read until terminator
|
|
95
96
|
if length is None:
|
|
@@ -106,8 +107,8 @@ if sys.platform == "win32":
|
|
|
106
107
|
break
|
|
107
108
|
return response
|
|
108
109
|
finally:
|
|
109
|
-
SetConsoleMode(GetStdHandle(-10), ctypes.byref(prev_stdin_mode))
|
|
110
|
-
SetConsoleMode(GetStdHandle(-11), ctypes.byref(prev_stdout_mode))
|
|
110
|
+
kernel32.SetConsoleMode(kernel32.GetStdHandle(-10), ctypes.byref(prev_stdin_mode))
|
|
111
|
+
kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), ctypes.byref(prev_stdout_mode))
|
|
111
112
|
|
|
112
113
|
else:
|
|
113
114
|
# Not Windows, so use termios/fcntl:
|
|
@@ -304,7 +305,8 @@ def color_support(interactive=True, debug=False) -> ColorSupport:
|
|
|
304
305
|
return ColorSupport.NONE
|
|
305
306
|
|
|
306
307
|
env_term = os.environ.get("TERM", "")
|
|
307
|
-
|
|
308
|
+
if debug:
|
|
309
|
+
print(f"$TERM is {env_term}")
|
|
308
310
|
|
|
309
311
|
truecolor_terminals = ("truecolor", "xterm-kitty", "xterm-ghostty", "wezterm")
|
|
310
312
|
if env_term in truecolor_terminals:
|
|
@@ -362,6 +364,13 @@ def color_support(interactive=True, debug=False) -> ColorSupport:
|
|
|
362
364
|
return min(ColorSupport.ANSI_24BIT, color_cap)
|
|
363
365
|
|
|
364
366
|
if curses:
|
|
367
|
+
# Curses is installed, but it may or may not have been set up. Try and see
|
|
368
|
+
try:
|
|
369
|
+
curses.tigetflag("RGB")
|
|
370
|
+
# If this gets an error, it can be an internal type not derived from Exception
|
|
371
|
+
# so just catch everything:
|
|
372
|
+
except: # pylint: disable=bare-except
|
|
373
|
+
curses.setupterm()
|
|
365
374
|
if curses.tigetflag("RGB") == 1:
|
|
366
375
|
# ncurses 6.0+ / terminfo added an "RGB" capability to indicate truecolor support
|
|
367
376
|
return min(ColorSupport.ANSI_24BIT, color_cap)
|
|
@@ -371,6 +380,8 @@ def color_support(interactive=True, debug=False) -> ColorSupport:
|
|
|
371
380
|
if curses.tigetnum("colors") == 256:
|
|
372
381
|
return min(ColorSupport.ANSI_8BIT, color_cap)
|
|
373
382
|
|
|
383
|
+
curses_colors = curses.tigetnum("colors") # for use below
|
|
384
|
+
|
|
374
385
|
if env_term.endswith("-256color") or env_term.endswith("-256"):
|
|
375
386
|
if debug:
|
|
376
387
|
print("Color detect: $TERM suffix in 8b list")
|
|
@@ -391,8 +402,9 @@ def color_support(interactive=True, debug=False) -> ColorSupport:
|
|
|
391
402
|
print("Color detect: using terminal's Device Attributes")
|
|
392
403
|
return da1_color_support(debug)
|
|
393
404
|
|
|
394
|
-
if curses
|
|
395
|
-
|
|
405
|
+
if curses:
|
|
406
|
+
if curses_colors >= 16:
|
|
407
|
+
return min(ColorSupport.ANSI_4BIT, color_cap)
|
|
396
408
|
|
|
397
409
|
return ColorSupport.NONE
|
|
398
410
|
|
|
@@ -463,3 +475,51 @@ def histplot2d(
|
|
|
463
475
|
p.upscale(max_expansion=(scale, scale))
|
|
464
476
|
|
|
465
477
|
return p
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def densityplot2d(
|
|
481
|
+
points: Sequence[tuple[FloatLike, FloatLike]],
|
|
482
|
+
kernel: Optional[smoothing.SmoothingFunc] = None,
|
|
483
|
+
bins: (
|
|
484
|
+
int
|
|
485
|
+
| tuple[int, int]
|
|
486
|
+
| Sequence[FloatLike]
|
|
487
|
+
| tuple[Sequence[FloatLike], Sequence[FloatLike]]
|
|
488
|
+
) = 0,
|
|
489
|
+
ranges: Optional[tuple[Optional[ValueRange], Optional[ValueRange]]] = None,
|
|
490
|
+
align=True,
|
|
491
|
+
colors=FADE_IN,
|
|
492
|
+
border_line=True,
|
|
493
|
+
fractional_tick_pos=False,
|
|
494
|
+
**plotargs,
|
|
495
|
+
# pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
|
|
496
|
+
):
|
|
497
|
+
"""Wrapper for smoothing.smooth2d / plot.Plot to simplify 2-D density plots"""
|
|
498
|
+
|
|
499
|
+
if bins == 0:
|
|
500
|
+
try:
|
|
501
|
+
terminal_size: Optional[os.terminal_size] = os.get_terminal_size()
|
|
502
|
+
except OSError:
|
|
503
|
+
terminal_size = plotmodule.default_terminal_size
|
|
504
|
+
if terminal_size is None:
|
|
505
|
+
raise OSError("No terminal size from os.get_terminal_size()")
|
|
506
|
+
size_x = terminal_size.columns - 10
|
|
507
|
+
size_y = terminal_size.lines - 4
|
|
508
|
+
bins = (size_x, size_y)
|
|
509
|
+
|
|
510
|
+
if kernel is None:
|
|
511
|
+
x_width, y_width = smoothing.pick_kernel_bandwidth(points, bins=(size_x, size_y))
|
|
512
|
+
kernel = smoothing.gaussian_with_sigmas(x_width, y_width)
|
|
513
|
+
|
|
514
|
+
smoothed, x_axis, y_axis = smoothing.smooth2d(
|
|
515
|
+
points=points,
|
|
516
|
+
kernel=kernel,
|
|
517
|
+
bins=bins,
|
|
518
|
+
ranges=ranges,
|
|
519
|
+
align=align,
|
|
520
|
+
border_line=border_line,
|
|
521
|
+
fractional_tick_pos=fractional_tick_pos,
|
|
522
|
+
)
|
|
523
|
+
p = plot(smoothed, colors, x_axis=x_axis, y_axis=y_axis, **plotargs)
|
|
524
|
+
|
|
525
|
+
return p
|
densitty/plot.py
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
"""Two-dimensional histogram (density plot) with textual output."""
|
|
2
2
|
|
|
3
3
|
import dataclasses
|
|
4
|
-
from itertools import chain, zip_longest
|
|
5
4
|
import os
|
|
6
5
|
import sys
|
|
7
|
-
|
|
6
|
+
import typing
|
|
7
|
+
from itertools import chain, zip_longest
|
|
8
|
+
from typing import Any, Callable, Optional, Sequence
|
|
9
|
+
|
|
10
|
+
from . import ansi, lineart
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
from .
|
|
12
|
+
if typing.TYPE_CHECKING:
|
|
13
|
+
from .axis import Axis
|
|
14
|
+
from .util import FloatLike
|
|
15
|
+
else:
|
|
16
|
+
Axis = Any
|
|
17
|
+
FloatLike = Any
|
|
11
18
|
|
|
12
19
|
# pylint: disable=invalid-name
|
|
13
20
|
# User can set this to provide a default if os.terminal_size() fails:
|
|
@@ -27,8 +34,8 @@ class Plot:
|
|
|
27
34
|
font_mapping: dict = dataclasses.field(default_factory=lambda: lineart.basic_font)
|
|
28
35
|
min_data: Optional[FloatLike] = None
|
|
29
36
|
max_data: Optional[FloatLike] = None
|
|
30
|
-
x_axis: Optional[
|
|
31
|
-
y_axis: Optional[
|
|
37
|
+
x_axis: Optional[Axis] = None
|
|
38
|
+
y_axis: Optional[Axis] = None
|
|
32
39
|
flip_y: bool = True # put the first row of data at the bottom of the output
|
|
33
40
|
|
|
34
41
|
def as_ascii(self):
|
|
@@ -98,10 +105,11 @@ class Plot:
|
|
|
98
105
|
|
|
99
106
|
if self.y_axis:
|
|
100
107
|
axis_lines = self.y_axis.render_as_y(num_rows, False, bool(self.x_axis), self.flip_y)
|
|
108
|
+
left_margin = lineart.display_len(axis_lines[0])
|
|
101
109
|
else:
|
|
102
110
|
axis_lines = ["" for _ in range(num_rows + bool(self.x_axis))]
|
|
111
|
+
left_margin = 0
|
|
103
112
|
|
|
104
|
-
left_margin = lineart.display_len(axis_lines[0])
|
|
105
113
|
if self.x_axis:
|
|
106
114
|
x_ticks, x_labels = self.x_axis.render_as_x(num_cols, left_margin)
|
|
107
115
|
axis_lines[-1] = lineart.merge_lines(x_ticks, axis_lines[-1])
|
densitty/smoothing.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Creation of 2-D density maps for (x,y) data"""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import math
|
|
5
|
+
from typing import Callable, Optional, Sequence
|
|
6
|
+
|
|
7
|
+
from .axis import Axis
|
|
8
|
+
from .binning import expand_bins_arg, histogram2d, process_bin_args
|
|
9
|
+
from .util import FloatLike, ValueRange, partial_first, partial_second
|
|
10
|
+
|
|
11
|
+
BareSmoothingFunc = Callable[[FloatLike, FloatLike], FloatLike]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclasses.dataclass
|
|
15
|
+
class SmoothingFuncWithWidth:
|
|
16
|
+
"""Smoothing function plus precalculated widths"""
|
|
17
|
+
|
|
18
|
+
func: BareSmoothingFunc
|
|
19
|
+
# Precalculated widths at certain fractional height (0.5 and 0.001):
|
|
20
|
+
precalc_widths: dict[FloatLike, tuple[FloatLike, FloatLike]]
|
|
21
|
+
|
|
22
|
+
def __call__(self, delta_x: FloatLike, delta_y: FloatLike) -> FloatLike:
|
|
23
|
+
return self.func(delta_x, delta_y)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
SmoothingFunc = BareSmoothingFunc | SmoothingFuncWithWidth
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def gaussian(
|
|
30
|
+
delta: tuple[FloatLike, FloatLike],
|
|
31
|
+
inv_cov: tuple[tuple[FloatLike, FloatLike], tuple[FloatLike, FloatLike]],
|
|
32
|
+
):
|
|
33
|
+
"""Unnormalized Gaussian
|
|
34
|
+
delta: vector of ((x - x0), (y - y0))
|
|
35
|
+
inv_cov: inverse covariance matrix (aka precision)
|
|
36
|
+
"""
|
|
37
|
+
exponent = (
|
|
38
|
+
(delta[0] * delta[0] * inv_cov[0][0])
|
|
39
|
+
+ 2 * (delta[0] * delta[1] * inv_cov[0][1])
|
|
40
|
+
+ (delta[1] * delta[1] * inv_cov[1][1])
|
|
41
|
+
)
|
|
42
|
+
return math.exp(-exponent / 2)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def gaussian_with_inv_cov(inv_cov) -> SmoothingFunc:
|
|
46
|
+
"""Produce a kernel function for a Gaussian with specified inverse covariance"""
|
|
47
|
+
|
|
48
|
+
def out(delta_x: FloatLike, delta_y: FloatLike) -> FloatLike:
|
|
49
|
+
return gaussian((delta_x, delta_y), inv_cov)
|
|
50
|
+
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def gaussian_with_sigmas(sigma_x, sigma_y) -> SmoothingFunc:
|
|
55
|
+
"""Produce a kernel function for a Gaussian with specified X & Y widths"""
|
|
56
|
+
inv_cov = ((sigma_x**-2, 0), (0, sigma_y**-2))
|
|
57
|
+
return gaussian_with_inv_cov(inv_cov)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def covariance(points: Sequence[tuple[FloatLike, FloatLike]]):
|
|
61
|
+
"""Calculate the covariance matrix of a list of points"""
|
|
62
|
+
num = len(points)
|
|
63
|
+
xs = tuple(x for x, _ in points)
|
|
64
|
+
ys = tuple(y for _, y in points)
|
|
65
|
+
mean_x = sum(xs) / num
|
|
66
|
+
mean_y = sum(ys) / num
|
|
67
|
+
cov_xx = sum((x - mean_x) ** 2 for x in xs) / num
|
|
68
|
+
cov_yy = sum((y - mean_y) ** 2 for y in ys) / num
|
|
69
|
+
cov_xy = sum((x - mean_x) * (y - mean_y) for x, y in points) / num
|
|
70
|
+
return ((cov_xx, cov_xy), (cov_xy, cov_yy))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def kde(points: Sequence[tuple[FloatLike, FloatLike]]):
|
|
74
|
+
"""Kernel for Kernel Density Estimation
|
|
75
|
+
Note that the resulting smoothing function is quite broad, since the
|
|
76
|
+
covariance estimate converges very slowly.
|
|
77
|
+
|
|
78
|
+
This may make sense to use if the distribution of points is itself a
|
|
79
|
+
Gaussian, but makes much less sense if it has any internal structure,
|
|
80
|
+
as that will all get smoothed out.
|
|
81
|
+
"""
|
|
82
|
+
# From Scott's rule / Silverman's factor: Bandwidth is n**(-1/6)
|
|
83
|
+
# That is to scale the std deviation (characteristic width)
|
|
84
|
+
# We're using the square of that: (co)variance, so scale by n**(-1/3)
|
|
85
|
+
# And invert to get something we can pass to the gaussian func
|
|
86
|
+
cov = covariance(points)
|
|
87
|
+
scale = len(points) ** (1 / 3)
|
|
88
|
+
scaled_det = scale * (cov[0][0] * cov[1][1] - cov[1][0] * cov[0][1])
|
|
89
|
+
inv_scaled_cov = (
|
|
90
|
+
(cov[1][1] / scaled_det, -cov[0][1] / scaled_det),
|
|
91
|
+
(-cov[1][0] / scaled_det, cov[0][0] / scaled_det),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return gaussian_with_inv_cov(inv_scaled_cov)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def triangle(width_x, width_y) -> SmoothingFunc:
|
|
98
|
+
"""Produce a kernel function for a 2-D triangle with specified width/height
|
|
99
|
+
This is much cheaper computationally than the Gaussian, and gives decent results.
|
|
100
|
+
It has the nice property that if the widths are multiples of the output "bin" size,
|
|
101
|
+
the total output weight is independent of the exact alignment of the output bins.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def out(delta_x: FloatLike, delta_y: FloatLike) -> FloatLike:
|
|
105
|
+
x_factor = max(0.0, width_x / 2 - abs(delta_x))
|
|
106
|
+
y_factor = max(0.0, width_y / 2 - abs(delta_y))
|
|
107
|
+
return x_factor * y_factor
|
|
108
|
+
|
|
109
|
+
return SmoothingFuncWithWidth(
|
|
110
|
+
out,
|
|
111
|
+
{
|
|
112
|
+
0.5: (width_x / 4, width_y / 4),
|
|
113
|
+
0.001: (width_x / 2, width_y / 2),
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def pick_kernel_bandwidth(
|
|
119
|
+
points: Sequence[tuple[FloatLike, FloatLike]],
|
|
120
|
+
bins: tuple[int, int],
|
|
121
|
+
ranges: Optional[tuple[Optional[ValueRange], Optional[ValueRange]]] = None,
|
|
122
|
+
smoothness: FloatLike = 3,
|
|
123
|
+
smooth_fraction: FloatLike = 0.5,
|
|
124
|
+
) -> tuple[float, float]:
|
|
125
|
+
"""Determine an 'optimal' width for a kernel based on histogram binning
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
points: Sequence of X,Y points each should be (float, float)
|
|
130
|
+
bins: tuple(int, int)
|
|
131
|
+
expected output number of columns/rows in plot
|
|
132
|
+
so kernel will at least be on the order of one bin
|
|
133
|
+
ranges: optional tuple of ValueRanges
|
|
134
|
+
expected output plot range. Determined from data if unset.
|
|
135
|
+
smoothness: float
|
|
136
|
+
Number of points in a histogram bin that is deemed "smooth"
|
|
137
|
+
1: minumum smoothing. 3 gives reasonable results.
|
|
138
|
+
smooth_fraction: float (fraction 0.0..1.0)
|
|
139
|
+
fraction of non-zero bins that must have the desired smoothness
|
|
140
|
+
0.5 => median non-zero bin
|
|
141
|
+
"""
|
|
142
|
+
if bins[0] <= 0 or bins[1] <= 0:
|
|
143
|
+
raise ValueError("Number of bins must be nonzero")
|
|
144
|
+
|
|
145
|
+
while bins[0] > 0 and bins[1] > 0:
|
|
146
|
+
binned, x_axis, y_axis = histogram2d(points, bins, ranges, align=False)
|
|
147
|
+
nonzero_bins = [b for row in binned for b in row if b > 0]
|
|
148
|
+
test_pos = int(len(nonzero_bins) * (1.0 - smooth_fraction))
|
|
149
|
+
test_val = sorted(nonzero_bins)[test_pos]
|
|
150
|
+
if test_val >= smoothness:
|
|
151
|
+
break
|
|
152
|
+
bins = (bins[0] - 1, bins[1] - 1)
|
|
153
|
+
else:
|
|
154
|
+
# We never managed to get 'smoothness' per bin, so just give up and smooth a lot
|
|
155
|
+
bins = (1, 1)
|
|
156
|
+
|
|
157
|
+
x_width = float(x_axis.value_range.max - x_axis.value_range.min) / bins[0] / 4
|
|
158
|
+
y_width = float(y_axis.value_range.max - y_axis.value_range.min) / bins[1] / 4
|
|
159
|
+
|
|
160
|
+
return (x_width, y_width)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def func_span(f: Callable, fractional_height: FloatLike):
|
|
164
|
+
"""Calculate the half-width of function at specified height"""
|
|
165
|
+
maximum = f(0)
|
|
166
|
+
target = maximum * fractional_height
|
|
167
|
+
# variables 'upper' and 'lower' s.t. f(lower) > maximum/3 and f(upper) < maximum/2
|
|
168
|
+
lower, upper = 0.0, 1.0
|
|
169
|
+
# Interval might not contain target, so double 'upper' until it does
|
|
170
|
+
for _ in range(100):
|
|
171
|
+
if f(upper) <= target:
|
|
172
|
+
break
|
|
173
|
+
lower = upper
|
|
174
|
+
upper *= 2
|
|
175
|
+
else:
|
|
176
|
+
raise ValueError("Unable to compute kernel function half-width")
|
|
177
|
+
|
|
178
|
+
# If our initial interval did contain target, the interval may be orders of magnitude too large
|
|
179
|
+
# We'll bisect until 'lower' moves, then bisect 10 times more
|
|
180
|
+
iter_count = 0
|
|
181
|
+
for _ in range(100):
|
|
182
|
+
test = (lower + upper) / 2
|
|
183
|
+
if f(test) < target:
|
|
184
|
+
upper = test
|
|
185
|
+
else:
|
|
186
|
+
lower = test
|
|
187
|
+
if lower > 0:
|
|
188
|
+
iter_count += 1
|
|
189
|
+
if iter_count >= 10:
|
|
190
|
+
break
|
|
191
|
+
else:
|
|
192
|
+
raise ValueError("Unable to compute kernel function half-width")
|
|
193
|
+
|
|
194
|
+
return (lower + upper) / 2
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def func_width_at_height(f: SmoothingFunc, height_fraction: float) -> tuple[FloatLike, FloatLike]:
|
|
198
|
+
"""Helper to calculate function width at a given fractional height."""
|
|
199
|
+
if isinstance(f, SmoothingFuncWithWidth) and height_fraction in f.precalc_widths:
|
|
200
|
+
return f.precalc_widths[height_fraction]
|
|
201
|
+
x_width = func_span(partial_first(f), height_fraction)
|
|
202
|
+
y_width = func_span(partial_second(f), height_fraction)
|
|
203
|
+
if isinstance(f, SmoothingFuncWithWidth):
|
|
204
|
+
f.precalc_widths[height_fraction] = (x_width, y_width)
|
|
205
|
+
return x_width, y_width
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def func_width_half_height(f: SmoothingFunc) -> tuple[FloatLike, FloatLike]:
|
|
209
|
+
"""Provide the (half) width of the function at half height (HWHM)"""
|
|
210
|
+
return func_width_at_height(f, 0.5)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def func_width(f: SmoothingFunc) -> tuple[FloatLike, FloatLike]:
|
|
214
|
+
"""Provide the (half) width of the function where it becomes negligible
|
|
215
|
+
|
|
216
|
+
Note: here we're just finding where the function gets down to 1/1000 of max,
|
|
217
|
+
which neglects that the area scales with the radius from the function center,
|
|
218
|
+
so for very slowly decaying functions (1/r, say) we may be excluding a lot of total weight.
|
|
219
|
+
"""
|
|
220
|
+
return func_width_at_height(f, 0.001)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def smooth_to_bins(
|
|
224
|
+
points: Sequence[tuple[FloatLike, FloatLike]],
|
|
225
|
+
kernel: SmoothingFunc,
|
|
226
|
+
x_centers: Sequence[FloatLike],
|
|
227
|
+
y_centers: Sequence[FloatLike],
|
|
228
|
+
) -> Sequence[Sequence[float]]:
|
|
229
|
+
"""Bin points into a 2-D histogram given bin edges
|
|
230
|
+
|
|
231
|
+
Parameters
|
|
232
|
+
----------
|
|
233
|
+
points: Sequence of (X,Y) tuples: the data points to smooth
|
|
234
|
+
kernel: Smoothing Function
|
|
235
|
+
x_centers: Sequence of values: Centers of output columns
|
|
236
|
+
y_centers: Sequence of values: Centers of output rows
|
|
237
|
+
"""
|
|
238
|
+
# pylint: disable=too-many-locals
|
|
239
|
+
x_ctr_f = [float(x) for x in x_centers]
|
|
240
|
+
y_ctr_f = [float(y) for y in y_centers]
|
|
241
|
+
|
|
242
|
+
out = [[0.0] * len(x_centers) for _ in range(len(y_centers))]
|
|
243
|
+
|
|
244
|
+
# Make the assumption that the bin centers are evenly spaced, so we can
|
|
245
|
+
# calculate bin position from index and vice versa
|
|
246
|
+
x_delta = x_ctr_f[1] - x_ctr_f[0]
|
|
247
|
+
y_delta = y_ctr_f[1] - y_ctr_f[0]
|
|
248
|
+
|
|
249
|
+
kernel_width = func_width(kernel)
|
|
250
|
+
# Find width of the kernel in terms of X/Y indexes of the centers:
|
|
251
|
+
kernel_width_di = (
|
|
252
|
+
round(kernel_width[0] / x_delta) + 1,
|
|
253
|
+
round(kernel_width[1] / y_delta) + 1,
|
|
254
|
+
)
|
|
255
|
+
for point in points:
|
|
256
|
+
p = (float(point[0]), float(point[1]))
|
|
257
|
+
min_xi = max(round((p[0] - x_ctr_f[0]) / x_delta) - kernel_width_di[0], 0)
|
|
258
|
+
min_yi = max(round((p[1] - y_ctr_f[0]) / y_delta) - kernel_width_di[1], 0)
|
|
259
|
+
|
|
260
|
+
for x_i, bin_x in enumerate(x_ctr_f[min_xi : min_xi + 2 * kernel_width_di[0]], min_xi):
|
|
261
|
+
for y_i, bin_y in enumerate(y_ctr_f[min_yi : min_yi + 2 * kernel_width_di[1]], min_yi):
|
|
262
|
+
out[y_i][x_i] += float(kernel((p[0] - bin_x), (p[1] - bin_y)))
|
|
263
|
+
return out
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def smooth2d(
|
|
267
|
+
points: Sequence[tuple[FloatLike, FloatLike]],
|
|
268
|
+
kernel: SmoothingFunc,
|
|
269
|
+
bins: (
|
|
270
|
+
int
|
|
271
|
+
| tuple[int, int]
|
|
272
|
+
| Sequence[FloatLike]
|
|
273
|
+
| tuple[Sequence[FloatLike], Sequence[FloatLike]]
|
|
274
|
+
) = 10,
|
|
275
|
+
ranges: Optional[tuple[Optional[ValueRange], Optional[ValueRange]]] = None,
|
|
276
|
+
align=True,
|
|
277
|
+
**axis_args,
|
|
278
|
+
) -> tuple[Sequence[Sequence[float]], Axis, Axis]:
|
|
279
|
+
"""Smooth (x,y) points out into a 2-D Density plot
|
|
280
|
+
|
|
281
|
+
Parameters
|
|
282
|
+
----------
|
|
283
|
+
points: Sequence of (X,Y) tuples: the points to smooth into "bins"
|
|
284
|
+
kernel: SmoothingFunc
|
|
285
|
+
Smoothing function, takes (delta_x, delta_y) and outputs value
|
|
286
|
+
bins: int or (int, int) or [float,...] or ([float,...], [float,...])
|
|
287
|
+
int: number of output rows & columns (default: 10)
|
|
288
|
+
(int,int): number of columns (X), rows (Y)
|
|
289
|
+
list[float]: Column/Row centers
|
|
290
|
+
(list[float], list[float]): column centers for X, column centers for Y
|
|
291
|
+
ranges: Optional (ValueRange, ValueRange)
|
|
292
|
+
((x_min, x_max), (y_min, y_max)) for the row/column centers if 'bins' is int
|
|
293
|
+
Default: take from data min/max, with buffer based on kernel width
|
|
294
|
+
align: bool (default: True)
|
|
295
|
+
pick bin edges at 'round' values if # of bins is provided
|
|
296
|
+
drop_outside: bool (default: True)
|
|
297
|
+
True: Drop any data points outside the ranges
|
|
298
|
+
False: Put any outside points in closest bin (i.e. edge bins include outliers)
|
|
299
|
+
axis_args: Extra arguments to pass through to Axis constructor
|
|
300
|
+
|
|
301
|
+
returns: Sequence[Sequence[int]], (x-)Axis, (y-)Axis
|
|
302
|
+
"""
|
|
303
|
+
|
|
304
|
+
expanded_bins = expand_bins_arg(bins)
|
|
305
|
+
|
|
306
|
+
if isinstance(expanded_bins[0], Sequence):
|
|
307
|
+
# we were given the bin centers, so just use them
|
|
308
|
+
padding: tuple[FloatLike, FloatLike] = (0, 0)
|
|
309
|
+
else:
|
|
310
|
+
# we're computing the bin centers, so include some padding based on kernel width
|
|
311
|
+
padding = func_width_half_height(kernel)
|
|
312
|
+
|
|
313
|
+
x_centers, y_centers = process_bin_args(points, expanded_bins, ranges, align, padding)
|
|
314
|
+
x_axis = Axis((x_centers[0], x_centers[-1]), values_are_edges=False, **axis_args)
|
|
315
|
+
y_axis = Axis((y_centers[0], y_centers[-1]), values_are_edges=False, **axis_args)
|
|
316
|
+
|
|
317
|
+
return (smooth_to_bins(points, kernel, x_centers, y_centers), x_axis, y_axis)
|
densitty/truecolor.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""ANSI "True color" (24b, 16M colors) support."""
|
|
2
2
|
|
|
3
|
+
import operator
|
|
3
4
|
import math
|
|
4
5
|
|
|
5
6
|
from typing import Optional, Sequence
|
|
@@ -37,7 +38,7 @@ def _linear_rgb_to_rgb(channel):
|
|
|
37
38
|
|
|
38
39
|
def _vector_transform(v, m):
|
|
39
40
|
"""Returns v * m, where v is a vector and m is a matrix (list of columns)."""
|
|
40
|
-
return [
|
|
41
|
+
return [sum(map(operator.mul, v, col)) for col in m]
|
|
41
42
|
|
|
42
43
|
|
|
43
44
|
def _rgb_to_lab(rgb: Vec) -> Vec:
|