From 80778f86377a9e4241b43b59edc2329613c28fb6 Mon Sep 17 00:00:00 2001 From: Hans Dembinski Date: Mon, 31 May 2021 00:33:16 +0200 Subject: [PATCH] Simulate colorblindness (#3) --- .gitignore | 1 + README.md | 25 ++++----- benchmarks/test_colorblindness.py | 43 +++++++++++++++ monolens/__init__.py | 2 +- monolens/lens.py | 89 ++++++++++++++++++++++-------- monolens/util.py | 91 +++++++++++++++++++++++++++++++ setup.cfg | 8 ++- 7 files changed, 218 insertions(+), 41 deletions(-) create mode 100644 benchmarks/test_colorblindness.py diff --git a/.gitignore b/.gitignore index 49b214d..67a3f38 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.egg-info __pycache__ monolens/_version.py +venv diff --git a/README.md b/README.md index e195cd6..f28b068 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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. [](https://pypi.org/project/monolens) @@ -8,16 +8,18 @@ Watch the demo on YouTube. [](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 - 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. @@ -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. diff --git a/benchmarks/test_colorblindness.py b/benchmarks/test_colorblindness.py new file mode 100644 index 0000000..fdf4af3 --- /dev/null +++ b/benchmarks/test_colorblindness.py @@ -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)) diff --git a/monolens/__init__.py b/monolens/__init__.py index ad8f5bd..11c1694 100644 --- a/monolens/__init__.py +++ b/monolens/__init__.py @@ -1,4 +1,4 @@ -from .version import version as __version__ # noqa +from ._version import version as __version__ # noqa def main(): diff --git a/monolens/lens.py b/monolens/lens.py index cdbe59d..ab9227b 100644 --- a/monolens/lens.py +++ b/monolens/lens.py @@ -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__() @@ -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() @@ -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() @@ -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): @@ -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()): @@ -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 diff --git a/monolens/util.py b/monolens/util.py index c212929..db04e12 100644 --- a/monolens/util.py +++ b/monolens/util.py @@ -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) diff --git a/setup.cfg b/setup.cfg index 15e46fe..c24d0ed 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,7 +3,7 @@ name = monolens version = attr: monolens._version.version author = Hans Dembinski author_email = hans.dembinski@gmail.com -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 @@ -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 =