Skip to content

Commit

Permalink
Simulate colorblindness (#3)
Browse files Browse the repository at this point in the history
  • Loading branch information
HDembinski authored May 30, 2021
1 parent 1f66a32 commit 80778f8
Show file tree
Hide file tree
Showing 7 changed files with 218 additions and 41 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
*.egg-info
__pycache__
monolens/_version.py
venv
25 changes: 10 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
# monolens

Show part of your screen in 8-bit grayscale.
View part of your screen in monochrome colors or in simulated protanopia, deuteranopia, or tritanopia.

[<img src="https://img.shields.io/pypi/v/monolens.svg">](https://pypi.org/project/monolens)

Watch the demo on YouTube.

[<img src="https://img.youtube.com/vi/f8FRBlSoqWQ/0.jpg">](https://youtu.be/f8FRBlSoqWQ)

Install with `pip install monolens` and then run `monolens` in a terminal or do it in one command with or `pipx run monolens`.
Install with `pip install monolens` and then run `monolens` in a terminal or do it in one
command with or `pipx run monolens`.

# Usage

<!-- usage begin -->

- Drag the lens around by holding a Mouse button down inside the window
- Resize the lens by pressing up, down, left, right
- To quit, press Escape, Q, or double click on the lens
- To move the lens to another screen, press S
- Resize the lens by pressing up, down, left, right
- Press Tab to switch between monochrome view and simulated protanopia, deuteranopia, tritanopia
- To move the lens to another screen, press M
- On OSX, you need to give Monolens permission to make screenshots, which is safe.

<!-- usage end -->
Expand All @@ -26,23 +28,16 @@ Install with `pip install monolens` and then run `monolens` in a terminal or do

- The app is tested on OSX and Linux. It may flicker when you move the lens (less so on
OSX). If you know how to fix this, please help. :)
- Pulling the lens to another screen is currently not supported. To switch screens,
press S.
- Pulling the lens to another screen is currently not supported. Press S to switch screens
instead.
- The lens actually uses a static screenshot which is updated as you move the lens around.
This trick is necessary, because an app cannot read the pixels under its own window.
Because of this, the pixels under the app are only updated when you move the lens away
first and then back.
- On OSX, an ordinary app is not allowed to read pixels outside of its window for security
reasons. Doing this is safe; Monolens has no networking code implemented at all and
will not store or send information about your pixels anywhere.
reasons, which is why this app needs special permissions. Doing this is safe; Monolens
contains no networking code and will neither store nor send your pixels anywhere.

# Future plans

- Support gestures to rescale the lens (pinch etc)
- Add filters that simulate color blindness as well
- Add a splash screen with a "do not show again" to explain usage.

# For developers

- You can run Monolens without installing it from the project folder via
`python -m monolens`. You need to install `pyside6` manually then.
43 changes: 43 additions & 0 deletions benchmarks/test_colorblindness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from monolens import util
import numpy as np
import numba as nb

src = np.empty((2073600, 4), np.uint8)
dst = np.empty((2073600, 4), np.uint8)
cb = util.cb_full[0]
a, r, g, b = util.argb


def _colorblindness_np(d, s, cb):
d[:, r] = cb[0, 0] * s[:, r] + cb[0, 1] * s[:, g] + cb[0, 2] * s[:, b]
d[:, g] = cb[1, 0] * s[:, r] + cb[1, 1] * s[:, g] + cb[1, 2] * s[:, b]
d[:, b] = cb[2, 0] * s[:, r] + cb[2, 1] * s[:, g] + cb[2, 2] * s[:, b]


@nb.njit
def _colorblindness_nb(d, s, cb):
for i, p in enumerate(s):
d[i, r] = cb[0, 0] * p[r] + cb[0, 1] * p[g] + cb[0, 2] * p[b]
d[i, g] = cb[1, 0] * p[r] + cb[1, 1] * p[g] + cb[1, 2] * p[b]
d[i, b] = cb[2, 0] * p[r] + cb[2, 1] * p[g] + cb[2, 2] * p[b]


@nb.njit(parallel=True)
def _colorblindness_nbp(d, s, cb):
for i in nb.prange(len(s)):
p = s[i]
d[i, r] = cb[0, 0] * p[r] + cb[0, 1] * p[g] + cb[0, 2] * p[b]
d[i, g] = cb[1, 0] * p[r] + cb[1, 1] * p[g] + cb[1, 2] * p[b]
d[i, b] = cb[2, 0] * p[r] + cb[2, 1] * p[g] + cb[2, 2] * p[b]


def test_colorblindness_np(benchmark):
benchmark(lambda: _colorblindness_np(dst, src, cb))


def test_colorblindness_nb(benchmark):
benchmark(lambda: _colorblindness_nb(dst, src, cb))


def test_colorblindness_nbp(benchmark):
benchmark(lambda: _colorblindness_nbp(dst, src, cb))
2 changes: 1 addition & 1 deletion monolens/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .version import version as __version__ # noqa
from ._version import version as __version__ # noqa


def main():
Expand Down
89 changes: 67 additions & 22 deletions monolens/lens.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from PySide6 import QtWidgets, QtCore, QtGui
from .util import clip, DEBUG
from . import util


class Lens(QtWidgets.QWidget):
_screenshot = None
_converted = None
_conversion_type = 0

def __init__(self):
super().__init__()
Expand All @@ -18,6 +20,8 @@ def __init__(self):
):
self.setAttribute(flag)
self._updateScreenshot(self.screen())
settings = QtCore.QSettings()
self._conversion_type = int(settings.value("conversion_type", "0"))

def paintEvent(self, event):
sgeo = self.screen().geometry()
Expand All @@ -27,33 +31,40 @@ def paintEvent(self, event):
y = max(0, wgeo.y() - sgeo.y())
w = wgeo.width()
h = wgeo.height()
if DEBUG:
if util.DEBUG:
print("paint", x, y, w, h)
p = QtGui.QPainter(self)
p.drawImage(0, 0, self._screenshot, x * dpr, y * dpr, w * dpr, h * dpr)
p.drawImage(0, 0, self._converted, x * dpr, y * dpr, w * dpr, h * dpr)
p.setPen(QtGui.QPen(QtCore.Qt.white, 3))
p.drawRect(1, 1, w - 2, h - 2)
p.end()
super().paintEvent(event)

def resizeEvent(self, event):
if DEBUG < 2:
if util.DEBUG < 2:
self._updateScreenshot(self.screen())
self.repaint(0, 0, -1, -1) # better than update() on OSX
self._refresh()
super().resizeEvent(event)

def moveEvent(self, event):
if DEBUG < 2:
if util.DEBUG < 2:
self._updateScreenshot(self.screen())
self.repaint(0, 0, -1, -1) # better than update() on OS
self._refresh()
super().moveEvent(event)

def keyPressEvent(self, event):
key = event.key()
if key in (QtCore.Qt.Key_Escape, QtCore.Qt.Key_Q):
self.close()
elif key == QtCore.Qt.Key_S:
elif key == QtCore.Qt.Key_M:
self._moveToNextScreen()
elif key == QtCore.Qt.Key_Tab:
self._conversion_type += 1
if self._conversion_type == 4:
self._conversion_type = 0
QtCore.QSettings().setValue("conversion_type", self._conversion_type)
self._updateConverted()
self._refresh()
elif key == QtCore.Qt.Key_Left:
x = self.x() + 25
y = self.y()
Expand Down Expand Up @@ -105,30 +116,59 @@ def mouseDoubleClickEvent(self, event):
def _updateScreenshot(self, screen):
if not screen:
return
if DEBUG:
if util.DEBUG:
print("_updateScreenshot", screen.availableGeometry())
pix = screen.grabWindow(0).toImage()
screenshot = pix.convertToFormat(QtGui.QImage.Format_Grayscale8)
if self._screenshot:
pix = screen.grabWindow(0)
if (
not self._screenshot
or self._screenshot.width() != pix.width()
or self._screenshot.height() != pix.height()
):
self._screenshot = pix.toImage()
self._converted = QtGui.QImage(
pix.width(), pix.height(), QtGui.QImage.Format_RGB32
)
else:
# override lens with old pixels from previous screenshot
p = QtGui.QPainter(screenshot)
p = QtGui.QPainter(self._screenshot)
margin = 100 # heuristic
wgeo = self.geometry()
sgeo = screen.geometry()
wgeo = self.geometry()
dpr = self.devicePixelRatio()
x = max(0, wgeo.x() - sgeo.x() - margin)
y = max(0, wgeo.y() - sgeo.y() - margin)
w = min(wgeo.width() + 2 * margin, sgeo.width())
h = min(wgeo.height() + 2 * margin, sgeo.height())
x1 = max(0, wgeo.x() - sgeo.x() - margin)
y1 = max(0, wgeo.y() - sgeo.y() - margin)
x2 = x1 + min(wgeo.width() + 2 * margin, sgeo.width())
y2 = y1 + min(wgeo.height() + 2 * margin, sgeo.height())
# why first two arguments must be x, y instead of x * dpr, y * dpr?
p.drawImage(x, y, self._screenshot, x * dpr, y * dpr, w * dpr, h * dpr)
if util.DEBUG:
print("_updateScreenshot", x1, y1, x2, y2)
# region left of window
if x1 > 0:
p.drawPixmap(0, 0, pix, 0, 0, x1 * dpr, -1)
# region right of window
if x2 < sgeo.width():
p.drawPixmap(x2, 0, pix, x2 * dpr, 0, -1, -1)
# region above window
if y1 > 0:
p.drawPixmap(x1, 0, pix, x1 * dpr, 0, (x2 - x1) * dpr, y1 * dpr)
# region below window
if y2 < sgeo.height():
p.drawPixmap(x1, y2, pix, x1 * dpr, y2 * dpr, (x2 - x1) * dpr, -1)
p.end()
self._screenshot = screenshot
self._updateConverted()

def _updateConverted(self):
if self._conversion_type == 0:
util.grayscale(self._converted, self._screenshot)
else:
util.colorblindness(
self._converted, self._screenshot, self._conversion_type - 1
)

def _clipXY(self, x, y):
screen = self.screen().availableGeometry()
x = clip(x, screen.x(), screen.width() + screen.x() - self.width())
y = clip(y, screen.y(), screen.height() + screen.y() - self.height())
x = util.clip(x, screen.x(), screen.width() + screen.x() - self.width())
y = util.clip(y, screen.y(), screen.height() + screen.y() - self.height())
return x, y

def _clipAll(self, x, y, w, h):
Expand All @@ -146,6 +186,8 @@ def _clipAll(self, x, y, w, h):
def _moveToNextScreen(self):
# discover on which screen we are
screens = QtGui.QGuiApplication.screens()
if len(screens) == 1:
return
for iscr, scr in enumerate(screens):
sgeo = scr.geometry()
if sgeo.contains(self.geometry()):
Expand All @@ -160,3 +202,6 @@ def _moveToNextScreen(self):
x = ageo.center().x() - self.width() // 2
y = ageo.center().y() - self.height() // 2
self.move(x, y)

def _refresh(self):
self.repaint(0, 0, -1, -1) # better than update() on OSX
91 changes: 91 additions & 0 deletions monolens/util.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,100 @@
import os
import sys
from PySide6 import QtGui
import numpy as np
import numba as nb

DEBUG = int(os.environ.get("DEBUG", "0"))

if sys.byteorder == "little":
argb = (3, 2, 1, 0)
else:
argb = (0, 1, 2, 3)

# matrix values from colorblind package
cb_lms = np.array(
[
# Protanopia (red weakness)
[[0, 0.90822864, 0.008192], [0, 1, 0], [0, 0, 1]],
# Deuteranopia (green weakness)
[[1, 0, 0], [1.10104433, 0, -0.00901975], [0, 0, 1]],
# Tritanopia (blue weakness)
[[1, 0, 0], [0, 1, 0], [-0.15773032, 1.19465634, 0]],
],
)
rgb2lms = np.array(
[
[0.3904725, 0.54990437, 0.00890159],
[0.07092586, 0.96310739, 0.00135809],
[0.02314268, 0.12801221, 0.93605194],
],
)
lms2rgb = np.linalg.inv(rgb2lms)
cb_full = [np.linalg.multi_dot((lms2rgb, cbi, rgb2lms)) for cbi in cb_lms]


@nb.njit(cache=True)
def clip(x, xmin, xmax):
if x < xmin:
return xmin
return min(x, xmax)


class QImageArrayInterface:
__slots__ = ("__array_interface__",)

def __init__(self, image):
format = image.format()
assert format == QtGui.QImage.Format_RGB32

self.__array_interface__ = {
"shape": (image.width() * image.height(), 4),
"typestr": "|u1",
"data": image.bits(),
"version": 3,
}


def qimage_array_view(image):
return np.asarray(QImageArrayInterface(image))


@nb.njit(parallel=True, cache=True)
def _grayscale(d, s, argb):
a, r, g, b = argb
for i in nb.prange(len(s)):
sr = s[i, r]
sg = s[i, g]
sb = s[i, b]
c = clip(0.299 * sr + 0.587 * sg + 0.114 * sb, 0, 255)
d[i, r] = c
d[i, g] = c
d[i, b] = c


def grayscale(dest, source):
s = qimage_array_view(source)
d = qimage_array_view(dest)
_grayscale(d, s, argb)


@nb.njit(parallel=True, cache=True)
def _colorblindness(d, s, cb, argb):
a, r, g, b = argb
for i in nb.prange(len(s)):
sr = s[i, r]
sg = s[i, g]
sb = s[i, b]
dr = cb[0, 0] * sr + cb[0, 1] * sg + cb[0, 2] * sb
dg = cb[1, 0] * sr + cb[1, 1] * sg + cb[1, 2] * sb
db = cb[2, 0] * sr + cb[2, 1] * sg + cb[2, 2] * sb
d[i, r] = clip(dr, 0, 255)
d[i, g] = clip(dg, 0, 255)
d[i, b] = clip(db, 0, 255)


def colorblindness(dest, source, type):
s = qimage_array_view(source)
d = qimage_array_view(dest)
cb = cb_full[type]
_colorblindness(d, s, cb, argb)
8 changes: 5 additions & 3 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = monolens
version = attr: monolens._version.version
author = Hans Dembinski
author_email = [email protected]
description = Show part of your screen in 8-bit grayscale
description = View part of your screen in monochrome colors or in simulated protanopia, deuteranopia, or tritanopia
long_description = file: README.md
long_description_content_type = text/markdown
url = https://github.com/hdembinski/monolens
Expand All @@ -15,10 +15,12 @@ classifiers =
Operating System :: OS Independent

[options]
package_dir =
packages = monolens
python_requires = >=3.6
install_requires = pyside6
install_requires =
pyside6
numpy
numba

[options.entry_points]
console_scripts =
Expand Down

0 comments on commit 80778f8

Please sign in to comment.