Skip to content

Commit c8b3218

Browse files
Bug fixes for color contrast approach.
1 parent e7e1252 commit c8b3218

File tree

1 file changed

+229
-71
lines changed

1 file changed

+229
-71
lines changed

diplomat/wx_gui/probability_displayer.py

Lines changed: 229 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import Iterable, NamedTuple, Optional
77
import wx
88
import numpy as np
9+
from pygments import highlight
910

1011

1112
class DrawMode(IntEnum):
@@ -31,6 +32,204 @@ class DrawingInfo(NamedTuple):
3132
draw_commands: Iterable[DrawCommand]
3233

3334

35+
def srgb_to_linear_rgb(color, as_int8: bool = False):
36+
color = np.asarray(color)
37+
if as_int8:
38+
color = color / 255
39+
40+
return np.where(
41+
color <= 0.04045,
42+
color / 12.92,
43+
((color + 0.055) / 1.055) ** 2.4
44+
)
45+
46+
47+
def linear_rgb_to_srgb(color, as_int8: bool = False):
48+
color = np.where(
49+
color <= 0.0031308,
50+
12.92 * color,
51+
1.055 * color ** (1 / 2.4) - 0.055
52+
)
53+
return color if not as_int8 else (np.clip(color, 0, 1) * 255).astype(np.uint8)
54+
55+
56+
LRGB_TO_LMS = np.array([
57+
[0.4122214708, 0.5363325363, 0.0514459929],
58+
[0.2119034982, 0.6806995451, 0.1073969566],
59+
[0.0883024619, 0.2817188376, 0.6299787005]
60+
])
61+
LMS_TO_LRGB = np.array([
62+
[ 4.07674166, -3.30771159, 0.23096993],
63+
[-1.268438, 2.6097574, -0.3413194 ],
64+
[-0.00419609, -0.70341861, 1.7076147]
65+
])
66+
67+
LMS_PRIME_TO_LAB = np.array([
68+
[0.2104542553, 0.7936177850, -0.0040720468],
69+
[1.9779984951, -2.4285922050, 0.4505937099],
70+
[0.0259040371, 0.7827717662, -0.8086757660],
71+
])
72+
LAB_TO_LMS_PRIME = np.array([
73+
[1., 0.39633779, 0.21580376],
74+
[1.00000001, -0.10556134, -0.06385417],
75+
[1.00000005, -0.08948418, -1.29148554]
76+
])
77+
78+
79+
def linear_rgb_to_oklab(color):
80+
return ((color @ LRGB_TO_LMS.T) ** (1 / 3)) @ LMS_PRIME_TO_LAB.T
81+
82+
def oklab_to_linear_rgb(color):
83+
return ((color @ LAB_TO_LMS_PRIME.T) ** 3) @ LMS_TO_LRGB.T
84+
85+
def oklab_to_oklch(color):
86+
color = np.copy(color)
87+
color_view = np.atleast_2d(color)
88+
a, b = color_view[..., 1:]
89+
C = np.sqrt(a * a + b * b)
90+
h = np.arctan2(b, a)
91+
color_view[..., 1] = C
92+
color_view[..., 2] = h
93+
return color
94+
95+
def oklch_to_oklab(color):
96+
color = np.copy(color)
97+
color_view = np.atleast_2d(color)
98+
C, h = color_view[..., 1:]
99+
a = C * np.cos(h)
100+
b = C * np.sin(h)
101+
color_view[..., 1] = a
102+
color_view[..., 2] = b
103+
return color
104+
105+
def color_to_luminance(color):
106+
to_y = np.array([0.2126729, 0.7151522, 0.0721750])
107+
color = (color / 255) ** 2.4
108+
return np.dot(color, to_y)
109+
110+
def clamp_luminance_black_levels(y):
111+
return np.where(y < 0.022, np.where(y < 0, 0, y + (0.022 - y) ** 1.414), y)
112+
113+
def apca_contrast(fg_color, bg_color):
114+
fg_y = clamp_luminance_black_levels(color_to_luminance(fg_color))
115+
bg_y = clamp_luminance_black_levels(color_to_luminance(bg_color))
116+
s_apc = np.where(bg_y > fg_y, bg_y ** 0.56 - fg_y ** 0.57, bg_y ** 0.65 - fg_y ** 0.62) * 1.14
117+
return np.where(np.abs(s_apc) < 0.1, 0.0, np.where(s_apc < 0, (s_apc - 0.027) * 100, (s_apc + 0.027) * 100))
118+
119+
120+
def circle_line_intersection(circle, line):
121+
pass
122+
123+
124+
def contrastify_color(fg_color, bg_color, distance: float, as_int8: bool = False):
125+
fg_color = linear_rgb_to_oklab(srgb_to_linear_rgb(fg_color, as_int8))
126+
bg_color = linear_rgb_to_oklab(srgb_to_linear_rgb(fg_color, as_int8))
127+
initial_distance_sq = np.sum((fg_color - bg_color) ** 2, -1)
128+
129+
print(fg_color, bg_color)
130+
print(np.sqrt(initial_distance_sq))
131+
132+
# We want the plane on which the hue stays the same (same angle, any lightness)
133+
plane_norm_vec = np.zeros(fg_color.shape)
134+
plane_norm_vec[..., 1] = -fg_color[..., 2]
135+
plane_norm_vec[..., 2] = fg_color[..., 1]
136+
# Normalize the vector...
137+
plane_norm_vec /= np.sqrt(np.dot(plane_norm_vec, plane_norm_vec))
138+
139+
print(plane_norm_vec)
140+
141+
# Project background point onto the plane...
142+
bg_from_plane_delta = np.dot(bg_color, plane_norm_vec)
143+
nearest_bg_point_on_plane = bg_color - bg_from_plane_delta * plane_norm_vec
144+
remaining_distance = np.sqrt(distance * distance - bg_from_plane_delta * bg_from_plane_delta)
145+
print(bg_from_plane_delta, nearest_bg_point_on_plane)
146+
147+
fg_bg_delta = fg_color - nearest_bg_point_on_plane
148+
fg_to_bg_dist = np.sqrt(np.dot(fg_bg_delta, fg_bg_delta))
149+
print(fg_bg_delta, fg_to_bg_dist, remaining_distance)
150+
print()
151+
# Calculate all intersections, and direct compliments...
152+
# TODO: Actually compute 2d intersections of circle with bounds (rectangle) within valid color plane.
153+
# than pick nearest color...
154+
fg_shifted = (
155+
nearest_bg_point_on_plane + (remaining_distance / fg_to_bg_dist) * (fg_color - nearest_bg_point_on_plane)
156+
)
157+
158+
l_bounds = (0, 1)
159+
ab_bounds = (-0.5, 0.5)
160+
161+
print(linear_rgb_to_srgb(oklab_to_linear_rgb(np.where(fg_to_bg_dist < remaining_distance, fg_shifted, fg_color)), as_int8))
162+
return linear_rgb_to_srgb(oklab_to_linear_rgb(np.where(fg_to_bg_dist < remaining_distance, fg_shifted, fg_color)), as_int8)
163+
164+
165+
class WxPlotStyles:
166+
def __init__(self, widget: wx.Control, min_color_dist: float = 0.5, accented_alpha: float = 0.3):
167+
self.background_color = widget.GetBackgroundColour()
168+
self.foreground_color = widget.GetForegroundColour()
169+
170+
def apply_apca(fg, bg, desired_val):
171+
#return fg
172+
return wx.Colour(*contrastify_color(fg[:3], bg[:3], desired_val, True), 255)
173+
174+
def alpha_shift(color, salpha):
175+
return wx.Colour(*color[:3], int(color.Alpha() * salpha))
176+
177+
self.highlight_color = apply_apca(
178+
wx.SystemSettings.GetColour(wx.SYS_COLOUR_HIGHLIGHT),
179+
self.background_color,
180+
min_color_dist
181+
)
182+
self.highlight_color2 = alpha_shift(self.highlight_color, accented_alpha)
183+
184+
# WX widgets doesn't provide an error highlight color. Since the
185+
# highlight color doesn't typically match the foreground or
186+
# background, we take the complement of it as a second selection color
187+
# (This color happens to usually be a Blue, so this typically produces
188+
# a Red/Orange)
189+
self.error_color = apply_apca(
190+
wx.Colour(*(255 - np.asarray(self.highlight_color[:3])), self.highlight_color.Alpha()),
191+
self.background_color,
192+
min_color_dist
193+
)
194+
self.error_color2 = alpha_shift(self.error_color, accented_alpha)
195+
196+
self.fixed_error_color = apply_apca(
197+
wx.Colour(*(((
198+
np.asarray(self.background_color, int)
199+
+ np.asarray(self.foreground_color, int)
200+
) / 2).astype(int))),
201+
self.background_color,
202+
min_color_dist
203+
)
204+
self.fixed_error_color2 = alpha_shift(self.fixed_error_color, accented_alpha)
205+
206+
# All the pens and brushes we will need...
207+
self.transparent_pen = wx.Pen(self.highlight_color, 2, wx.PENSTYLE_TRANSPARENT)
208+
209+
# Primary highlight color (normal plot locations)...
210+
self.highlight_pen = wx.Pen(self.highlight_color, 2, wx.PENSTYLE_SOLID)
211+
self.highlight_pen2 = wx.Pen(self.highlight_color, 5, wx.PENSTYLE_SOLID)
212+
self.highlight_brush = wx.Brush(self.highlight_color2, wx.BRUSHSTYLE_SOLID)
213+
214+
self.error_pen = wx.Pen(self.error_color, 2, wx.PENSTYLE_SOLID)
215+
self.error_pen2 = wx.Pen(self.error_color, 5, wx.PENSTYLE_SOLID)
216+
self.error_brush = wx.Brush(self.error_color2, wx.BRUSHSTYLE_SOLID)
217+
218+
self.fixed_error_pen = wx.Pen(self.fixed_error_color, 2, wx.PENSTYLE_SOLID)
219+
self.fixed_error_pen2 = wx.Pen(self.fixed_error_color, 5, wx.PENSTYLE_SOLID)
220+
self.fixed_error_brush = wx.Brush(self.fixed_error_color2, wx.BRUSHSTYLE_SOLID)
221+
222+
self.indicator_brush = wx.Brush(self.foreground_color, wx.BRUSHSTYLE_SOLID)
223+
self.indicator_pen = wx.Pen(self.foreground_color, 5, wx.PENSTYLE_SOLID)
224+
self.indicator_pen2 = wx.Pen(self.foreground_color, 1, wx.PENSTYLE_SOLID)
225+
226+
def is_valid(self, widget: wx.Control):
227+
return (
228+
tuple(self.background_color[:3]) == tuple(widget.GetBackgroundColour()[:3])
229+
and tuple(self.foreground_color[:3]) == tuple(widget.GetForegroundColour()[:3])
230+
)
231+
232+
34233
class ProbabilityDisplayer(wx.Control):
35234
"""
36235
A custom wx.Control which displays a list of probabilities in the form of a line segment plot. Uses native colors
@@ -90,6 +289,8 @@ def __init__(
90289
self._segment_starts = None
91290
self._segment_fix_frames = None
92291

292+
self._styles = None
293+
93294
self._best_size = wx.Size(self.MIN_PROB_STEP * 5, max(height, (self.TRIANGLE_SIZE * 4) + self.TOP_PADDING))
94295
self.SetMinSize(self._best_size)
95296
self.SetInitialSize(self._best_size)
@@ -248,104 +449,61 @@ def on_draw(self, dc: wx.DC):
248449
if((not width) or (not height)):
249450
return
250451

452+
if(self._styles is not None and not self._styles.is_valid(self)):
453+
self._styles = None
454+
if(self._styles is None):
455+
self._styles = WxPlotStyles(self)
456+
s = self._styles
457+
251458
# Clear the background with the default color...
252459
dc.SetBackground(
253-
wx.Brush(self.GetBackgroundColour(), wx.BRUSHSTYLE_SOLID)
460+
wx.Brush(s.background_color, wx.BRUSHSTYLE_SOLID)
254461
)
255462
dc.Clear()
256463

257-
# Colors used in pens and brushes below...
258-
highlight_color = wx.SystemSettings.GetColour(wx.SYS_COLOUR_HIGHLIGHT)
259-
highlight_color2 = wx.Colour(
260-
*highlight_color[:3],
261-
int(highlight_color.Alpha() * 0.3)
262-
)
263-
# WX widgets doesn't provide an error highlight color. Since the
264-
# highlight color doesn't typically match the foreground or
265-
# background, we take the complement of it as a second selection color
266-
# (This color happens to usually be a Blue, so this typically produces
267-
# a Red/Orange)
268-
error_color = wx.Colour(
269-
255 - highlight_color.Red(),
270-
255 - highlight_color.Green(),
271-
255 - highlight_color.Blue(),
272-
highlight_color.Alpha()
273-
)
274-
error_color2 = wx.Colour(*error_color[:3], int(error_color.Alpha() * 0.3))
275-
276-
foreground_color = self.GetForegroundColour()
277-
278-
fixed_error_color = wx.Colour(*(((
279-
np.asarray(self.GetBackgroundColour(), int)
280-
+ np.asarray(foreground_color, int)
281-
) / 2).astype(int)))
282-
fixed_error_color2 = wx.Colour(
283-
*fixed_error_color[:3], int(fixed_error_color.Alpha() * 0.3)
284-
)
285-
286-
# All the pens and brushes we will need...
287-
transparent_pen = wx.Pen(highlight_color, 2, wx.PENSTYLE_TRANSPARENT)
288-
289-
# Primary highlight color (normal plot locations)...
290-
highlight_pen = wx.Pen(highlight_color, 2, wx.PENSTYLE_SOLID)
291-
highlight_pen2 = wx.Pen(highlight_color, 5, wx.PENSTYLE_SOLID)
292-
highlight_brush = wx.Brush(highlight_color2, wx.BRUSHSTYLE_SOLID)
293-
294-
error_pen = wx.Pen(error_color, 2, wx.PENSTYLE_SOLID)
295-
error_pen2 = wx.Pen(error_color, 5, wx.PENSTYLE_SOLID)
296-
error_brush = wx.Brush(error_color2, wx.BRUSHSTYLE_SOLID)
297-
298-
fixed_error_pen = wx.Pen(fixed_error_color, 2, wx.PENSTYLE_SOLID)
299-
fixed_error_pen2 = wx.Pen(fixed_error_color, 5, wx.PENSTYLE_SOLID)
300-
fixed_error_brush = wx.Brush(fixed_error_color2, wx.BRUSHSTYLE_SOLID)
301-
302-
indicator_brush = wx.Brush(foreground_color, wx.BRUSHSTYLE_SOLID)
303-
indicator_pen = wx.Pen(foreground_color, 5, wx.PENSTYLE_SOLID)
304-
indicator_pen2 = wx.Pen(foreground_color, 1, wx.PENSTYLE_SOLID)
305-
306464
# This patches the point drawing for the latest versions of wxWidgets, which don't respect the pen's width correctly...
307465
def draw_points(points, pen):
308466
top = np.round((np.asarray(points) - pen.GetWidth() / 2)).astype(int)
309467
args = np.concatenate([top, np.full(top.shape, pen.GetWidth(), dtype=int)], axis=-1)
310-
dc.DrawEllipseList(args, transparent_pen, wx.Brush(pen.GetColour(), wx.BRUSHSTYLE_SOLID))
468+
dc.DrawEllipseList(args, s.transparent_pen, wx.Brush(pen.GetColour(), wx.BRUSHSTYLE_SOLID))
311469

312470
# Compute the center and points to place on the line...
313471
draw_info = self._compute_points(height, width)
314472

315473
for seg_x in draw_info.segment_xs:
316474
seg_x = int(seg_x)
317-
dc.DrawLineList([[seg_x, 0, seg_x, height]], fixed_error_pen)
475+
dc.DrawLineList([[seg_x, 0, seg_x, height]], s.fixed_error_pen)
318476
dc.DrawPolygonList([[
319477
[seg_x - int(self.TRIANGLE_SIZE / 2), 0],
320478
[seg_x + int(self.TRIANGLE_SIZE / 2), 0],
321479
[seg_x, int(self.TRIANGLE_SIZE)]
322-
]], fixed_error_pen, fixed_error_brush)
480+
]], s.fixed_error_pen, s.fixed_error_brush)
323481

324482
for seg_x in draw_info.segment_fix_xs:
325483
seg_x = int(seg_x)
326484
dc.DrawPolygonList([[
327485
[seg_x - int(self.TRIANGLE_SIZE / 2), 0],
328486
[seg_x + int(self.TRIANGLE_SIZE / 2), 0],
329487
[seg_x, int(self.TRIANGLE_SIZE)]
330-
]], indicator_pen2, highlight_brush)
488+
]], s.indicator_pen2, s.highlight_brush)
331489

332490
# Plot all of the points the filled-in polygon underneath, and the line connecting the points...
333491
for draw_command in draw_info.draw_commands:
334492
if(draw_command.draw_mode == DrawMode.USER_MODIFIED):
335493
continue
336494

337495
if(draw_command.draw_mode == DrawMode.NORMAL):
338-
pen = highlight_pen
339-
pen2 = highlight_pen2
340-
brush = highlight_brush
496+
pen = s.highlight_pen
497+
pen2 = s.highlight_pen2
498+
brush = s.highlight_brush
341499
elif(draw_command.draw_mode == DrawMode.POORLY_LABELED):
342-
pen = error_pen
343-
pen2 = error_pen2
344-
brush = error_brush
500+
pen = s.error_pen
501+
pen2 = s.error_pen2
502+
brush = s.error_brush
345503
else:
346-
pen = fixed_error_pen
347-
pen2 = fixed_error_pen2
348-
brush = fixed_error_brush
504+
pen = s.fixed_error_pen
505+
pen2 = s.fixed_error_pen2
506+
brush = s.fixed_error_brush
349507

350508
poly_begin_point = (draw_command.points[0] + draw_command.point_before) / 2
351509
poly_end_point = (draw_command.points[-1] + draw_command.point_after) / 2
@@ -359,7 +517,7 @@ def draw_points(points, pen):
359517

360518
dc.DrawPolygonList(
361519
[np.concatenate((draw_command.points, wrap_polygon_points))],
362-
transparent_pen,
520+
s.transparent_pen,
363521
brush
364522
)
365523

@@ -370,21 +528,21 @@ def draw_points(points, pen):
370528
draw_points(draw_command.points.astype(int), pen2)
371529

372530
# Draw the current location indicating line, point and arrow, indicates which data point we are currently on.
373-
dc.DrawLineList([[int(draw_info.x_center), 0, int(draw_info.x_center), height]], indicator_pen2)
531+
dc.DrawLineList([[int(draw_info.x_center), 0, int(draw_info.x_center), height]], s.indicator_pen2)
374532
dc.DrawPolygonList([[
375533
[int(draw_info.x_center - self.TRIANGLE_SIZE), height],
376534
[int(draw_info.x_center + self.TRIANGLE_SIZE), height],
377535
[int(draw_info.x_center), height - int(self.TRIANGLE_SIZE * 1.5)]
378-
]], indicator_pen2, indicator_brush)
536+
]], s.indicator_pen2, s.indicator_brush)
379537
if(draw_info.center_draw_mode != DrawMode.USER_MODIFIED):
380-
draw_points([[int(draw_info.x_center), int(draw_info.y_center)]], indicator_pen)
538+
draw_points([[int(draw_info.x_center), int(draw_info.y_center)]], s.indicator_pen)
381539

382540
# If the user set the name of this probability display plot, write it to the top-left corner...
383541
if(self._text is not None):
384-
back_pen = wx.Pen(self.GetBackgroundColour(), 3, wx.PENSTYLE_SOLID)
385-
back_brush = wx.Brush(self.GetBackgroundColour(), wx.BRUSHSTYLE_SOLID)
386-
dc.SetTextBackground(self.GetBackgroundColour())
387-
dc.SetTextForeground(self.GetForegroundColour())
542+
back_pen = wx.Pen(s.background_color, 3, wx.PENSTYLE_SOLID)
543+
back_brush = wx.Brush(s.background_color, wx.BRUSHSTYLE_SOLID)
544+
dc.SetTextBackground(s.background_color)
545+
dc.SetTextForeground(s.foreground_color)
388546

389547
dc.SetFont(self.GetFont())
390548
size: wx.Size = dc.GetTextExtent(self._text)

0 commit comments

Comments
 (0)