Repository: vispy/vispy

Issue:
Implement pixel hinting in text visual
Would be nice to have pixel hinting such that text edges are more likely to land on a pixel boundary. In the image below, the two "1" glyphs have very different appearances due to their location relative to the pixel grid. 

![anticipation1](https://cloud.githubusercontent.com/assets/940580/3980464/d40c5914-2862-11e4-8980-9348db2a9d0e.png)

We might also consider better anialiasing methods--the one used appears just a bit soft to me.



Discussion:
Such hinting can lead to a jumpy appearance of text because it changes the distance between glyphs. I think Windows uses this technique, but e.g. OSX does not. You get consistent distances, at the cost of slightly more blurry text. I am +1 for improving the aa methods.

ping @rougier 

Article about this topic, with links to posts by Jeff Atwood and Joel Spolsky:
http://damieng.com/blog/2007/06/13/font-rendering-philosophies-of-windows-and-mac-os-x

Hinting can only be applied on the horizontal direction and requires an accurate rastering engine (no way for SDF)

See http://jcgt.org/published/0002/01/04/

However, for SDF, it is possible to improve rendering by using bicubic interpolation at big size and multi-samples at small sizes.

See https://github.com/rougier/glumpy/blob/master/glumpy/shaders/sdf-glyph.frag

+1 for using what @rougier posted, or something like it. It is likely that the SDF rendering could be greatly improved by someone who actually knew what they were doing (as opposed to me) :)


Resolving patch:
diff --git a/examples/basics/visuals/text_visual.py b/examples/basics/visuals/text_visual.py
--- a/examples/basics/visuals/text_visual.py
+++ b/examples/basics/visuals/text_visual.py
@@ -15,11 +15,11 @@ def __init__(self):
         self.font_size = 48.
         self.text = Text('', bold=True)
         self.apply_zoom()
-    
+
     def on_draw(self, event):
         gloo.clear(color='white')
         self.draw_visual(self.text)
-    
+
     def on_mouse_wheel(self, event):
         """Use the mouse wheel to zoom."""
         self.font_size *= 1.25 if event.delta[1] > 0 else 0.8
diff --git a/vispy/scene/visuals/text/text.py b/vispy/scene/visuals/text/text.py
--- a/vispy/scene/visuals/text/text.py
+++ b/vispy/scene/visuals/text/text.py
@@ -12,9 +12,11 @@
 
 import numpy as np
 from copy import deepcopy
+from os import path as op
 
 from ._sdf import SDFRenderer
-from ....gloo import TextureAtlas, set_state, IndexBuffer, VertexBuffer
+from ....gloo import (TextureAtlas, set_state, IndexBuffer, VertexBuffer,
+                      set_viewport, get_parameter)
 from ....gloo.wrappers import _check_valid
 from ....ext.six import string_types
 from ....util.fonts import _load_glyph
@@ -36,6 +38,8 @@ class TextureFont(object):
     def __init__(self, font, renderer):
         self._atlas = TextureAtlas()
         self._atlas.wrapping = 'clamp_to_edge'
+        self._kernel = np.load(op.join(op.dirname(__file__), '..', '..', '..',
+                                       'data', 'spatial-filters.npy'))
         self._renderer = renderer
         self._font = deepcopy(font)
         self._font['size'] = 256  # use high resolution point size for SDF
@@ -131,6 +135,9 @@ def _text_to_vbo(text, font, anchor_x, anchor_y, lowres_size):
     width = height = ascender = descender = 0
     ratio, slop = 1. / font.ratio, font.slop
     x_off = -slop
+    # Need to store the original viewport, because the font[char] will
+    # trigger SDF rendering, which changes our viewport
+    orig_viewport = get_parameter('viewport')
     for ii, char in enumerate(text):
         glyph = font[char]
         kerning = glyph['kerning'].get(prev, 0.) * ratio
@@ -151,6 +158,7 @@ def _text_to_vbo(text, font, anchor_x, anchor_y, lowres_size):
         width += x_move
         height = max(height, glyph['size'][1] - 2*slop)
         prev = char
+    set_viewport(*orig_viewport)
 
     # Tight bounding box (loose would be width, font.height /.asc / .desc)
     width -= glyph['advance'] * ratio - (glyph['size'][0] - 2*slop)
@@ -221,19 +229,116 @@ class Text(Visual):
         """
 
     FRAGMENT_SHADER = """
+        // Adapted from glumpy with permission
+        const float M_SQRT1_2 = 0.707106781186547524400844362104849039;
+        const float kernel_bias  = -0.234377;
+        const float kernel_scale = 1.241974;
+
         uniform sampler2D u_font_atlas;
+        uniform vec2 u_font_atlas_shape;
         uniform vec4 u_color;
+        uniform float u_npix;
+        uniform sampler2D u_kernel;
 
         varying vec2 v_texcoord;
         const float center = 0.5;
 
+        // CatRom interpolation code
+        vec4 filter1D_radius2(sampler2D kernel, float index, float x,
+                              vec4 c0, vec4 c1, vec4 c2, vec4 c3) {
+            float w, w_sum = 0.0;
+            vec4 r = vec4(0.0,0.0,0.0,0.0);
+            w = texture2D(kernel, vec2(0.500000+(x/2.0),index) ).r;
+            w = w*kernel_scale + kernel_bias;
+            r += c0 * w;
+            w = texture2D(kernel, vec2(0.500000-(x/2.0),index) ).r;
+            w = w*kernel_scale + kernel_bias;
+            r += c2 * w;
+            w = texture2D(kernel, vec2(0.000000+(x/2.0),index) ).r;
+            w = w*kernel_scale + kernel_bias;
+            r += c1 * w;
+            w = texture2D(kernel, vec2(1.000000-(x/2.0),index) ).r;
+            w = w*kernel_scale + kernel_bias;
+            r += c3 * w;
+            return r;
+        }
+
+        vec4 filter2D_radius2(sampler2D texture, sampler2D kernel, float index,
+                              vec2 uv, vec2 pixel) {
+            vec2 texel = uv/pixel - vec2(0.0,0.0) ;
+            vec2 f = fract(texel);
+            texel = (texel-fract(texel)+vec2(0.001,0.001))*pixel;
+            vec4 t0 = filter1D_radius2(kernel, index, f.x,
+                texture2D( texture, texel + vec2(-1,-1)*pixel),
+                texture2D( texture, texel + vec2(0,-1)*pixel),
+                texture2D( texture, texel + vec2(1,-1)*pixel),
+                texture2D( texture, texel + vec2(2,-1)*pixel));
+            vec4 t1 = filter1D_radius2(kernel, index, f.x,
+                texture2D( texture, texel + vec2(-1,0)*pixel),
+                texture2D( texture, texel + vec2(0,0)*pixel),
+                texture2D( texture, texel + vec2(1,0)*pixel),
+                texture2D( texture, texel + vec2(2,0)*pixel));
+            vec4 t2 = filter1D_radius2(kernel, index, f.x,
+                texture2D( texture, texel + vec2(-1,1)*pixel),
+                texture2D( texture, texel + vec2(0,1)*pixel),
+                texture2D( texture, texel + vec2(1,1)*pixel),
+                texture2D( texture, texel + vec2(2,1)*pixel));
+            vec4 t3 = filter1D_radius2(kernel, index, f.x,
+                texture2D( texture, texel + vec2(-1,2)*pixel),
+                texture2D( texture, texel + vec2(0,2)*pixel),
+                texture2D( texture, texel + vec2(1,2)*pixel),
+                texture2D( texture, texel + vec2(2,2)*pixel));
+            return filter1D_radius2(kernel, index, f.y, t0, t1, t2, t3);
+        }
+
+        vec4 CatRom(sampler2D texture, vec2 shape, vec2 uv) {
+            return filter2D_radius2(texture, u_kernel, 0.468750,
+                                    uv, 1.0/shape);
+        }
+
+        float contour(in float d, in float w)
+        {
+            return smoothstep(center - w, center + w, d);
+        }
+
+        float sample(sampler2D texture, vec2 uv, float w)
+        {
+            return contour(texture2D(texture, uv).r, w);
+        }
+
         void main(void) {
             vec4 color = u_color;
             vec2 uv = v_texcoord.xy;
-            vec4 rgb = texture2D(u_font_atlas, uv);
+            vec4 rgb;
+
+            // Use interpolation at high font sizes
+            if(u_npix >= 50.0)
+                rgb = CatRom(u_font_atlas, u_font_atlas_shape, uv);
+            else
+                rgb = texture2D(u_font_atlas, uv);
             float distance = rgb.r;
-            float width = fwidth(distance);
-            float alpha = smoothstep(center - width, center + width, distance);
+
+            // GLSL's fwidth = abs(dFdx(uv)) + abs(dFdy(uv))
+            float width = 0.5 * fwidth(distance);  // sharpens a bit
+
+            // Regular SDF
+            float alpha = contour(distance, width);
+
+            if (u_npix < 30.) {
+                // Supersample, 4 extra points
+                // Half of 1/sqrt2; you can play with this
+                float dscale = 0.5 * M_SQRT1_2;
+                vec2 duv = dscale * (dFdx(v_texcoord) + dFdy(v_texcoord));
+                vec4 box = vec4(v_texcoord-duv, v_texcoord+duv);
+                float asum = sample(u_font_atlas, box.xy, width)
+                           + sample(u_font_atlas, box.zw, width)
+                           + sample(u_font_atlas, box.xw, width)
+                           + sample(u_font_atlas, box.zy, width);
+                // weighted average, with 4 extra points having 0.5 weight
+                // each, so 1 + 0.5*4 = 3 is the divisor
+                alpha = (alpha + 0.5 * asum) / 3.0;
+            }
+
             gl_FragColor = vec4(color.rgb, color.a * alpha);
         }
         """
@@ -341,7 +446,10 @@ def draw(self, event=None):
 
         self._program.prepare()  # Force ModularProgram to set shaders
         # todo: do some testing to verify that the scaling is correct
-        ps = (self._font_size / 72) * 92
+        ps = (self._font_size / 72.) * 92.
+        self._program['u_npix'] = ps
+        self._program['u_font_atlas_shape'] = self._font._atlas.shape[:2]
+        self._program['u_kernel'] = self._font._kernel
         self._program['u_scale'] = ps * px_scale[0], ps * px_scale[1]
         self._program['u_rotation'] = self._rotation
         self._program['u_pos'] = self._pos


Test patch:
diff --git a/vispy/scene/visuals/tests/test_text.py b/vispy/scene/visuals/tests/test_text.py
--- a/vispy/scene/visuals/tests/test_text.py
+++ b/vispy/scene/visuals/tests/test_text.py
@@ -1,31 +1,19 @@
 # -*- coding: utf-8 -*-
-from nose.tools import assert_equal
-
-from vispy.app import Canvas
 from vispy.scene.visuals import Text
-from vispy import gloo
-from vispy.testing import requires_application
+from vispy.testing import (requires_application, TestingCanvas,
+                           assert_image_equal)
 
 
 @requires_application()
 def test_text():
     """Test basic text support"""
-    with Canvas(size=(100, 100)) as c:
-        text = Text('X', bold=True, font_size=30, color='w')
-        gloo.set_viewport(0, 0, *c.size)
-        gloo.clear(color=(0., 0., 0., 1.))
-        text.draw()
-
-        s = gloo.util._screenshot()
-        assert_equal(s.min(), 0)
-        assert_equal(s.max(), 255)
-
-        # let's just peek at the texture, make sure it has something
-        gloo.clear(color=(0., 0., 0., 1.))
-        gloo.util.draw_texture(text._font._atlas)
-        s = gloo.util._screenshot()
-        assert_equal(s.max(), 255)
-        assert_equal(s.min(), 0)
-
-if __name__ == '__main__':
-    test_text()
+    with TestingCanvas(bgcolor='w', size=(92, 92)) as c:
+        pos = [92 // 2] * 2
+        text = Text('testing', font_size=20, color='k',
+                    pos=pos, anchor_x='center', anchor_y='baseline')
+        c.draw_visual(text)
+        # This limit seems large, but the images actually match quite well...
+        # TODO: we should probably make more "modes" for assert_image_equal
+        # at some point
+        # Test image created in Illustrator CS5, 1"x1" output @ 92 DPI
+        assert_image_equal("screenshot", 'visuals/text1.png', limit=840)
diff --git a/vispy/testing/_testing.py b/vispy/testing/_testing.py
--- a/vispy/testing/_testing.py
+++ b/vispy/testing/_testing.py
@@ -13,8 +13,10 @@
 import inspect
 import base64
 try:
-    from nose.tools import nottest
+    from nose.tools import nottest, assert_equal, assert_true
 except ImportError:
+    assert_equal = assert_true = None
+
     class nottest(object):
         def __init__(self, *args):
             pass  # Avoid "object() takes no parameters"
@@ -264,7 +266,7 @@ def _has_scipy(min_version):
 def requires_scipy(min_version='0.13'):
     return np.testing.dec.skipif(not _has_scipy(min_version),
                                  'Requires Scipy version >= %s' % min_version)
-        
+
 
 def _save_failed_test(data, expect, filename):
     commit, error = run_subprocess(['git', 'rev-parse',  'HEAD'])
@@ -272,7 +274,7 @@ def _save_failed_test(data, expect, filename):
     name.insert(-1, commit.strip())
     filename = '/'.join(name)
     host = 'data.vispy.org'
-    
+
     # concatenate data, expect, and diff into a single image
     ds = data.shape
     es = expect.shape
@@ -289,11 +291,11 @@ def _save_failed_test(data, expect, filename):
         img = np.empty(shape, dtype=np.ubyte)
         img[:] = 255
         img[:ds[0], :ds[1], :ds[2]] = data
-        img[:es[0], ds[1]+1+es[1]:, :es[2]] = expect 
-    
+        img[:es[0], ds[1]+1+es[1]:, :es[2]] = expect
+
     png = make_png(img)
     conn = httplib.HTTPConnection(host)
-    req = urllib.urlencode({'name': filename, 
+    req = urllib.urlencode({'name': filename,
                             'data': base64.b64encode(png)})
     conn.request('POST', '/upload.py', req)
     response = conn.getresponse().read()
@@ -304,7 +306,7 @@ def _save_failed_test(data, expect, filename):
         print(response)
 
 
-def assert_image_equal(image, reference):
+def assert_image_equal(image, reference, limit=40):
     """Downloads reference image and compares with image
 
     Parameters
@@ -313,6 +315,8 @@ def assert_image_equal(image, reference):
         'screenshot' or image data
     reference: str
         'The filename on the remote ``test-data`` repository to download'
+    limit : int
+        Number of pixels that can differ in the image.
     """
     from ..gloo.util import _screenshot
     from ..util.dataio import read_png
@@ -320,9 +324,9 @@ def assert_image_equal(image, reference):
 
     if image == "screenshot":
         image = _screenshot(alpha=False)
-    ref = read_png(get_testing_file(reference))
+    ref = read_png(get_testing_file(reference))[:, :, :3]
 
-    assert image.shape == ref.shape
+    assert_equal(image.shape, ref.shape)
 
     # check for minimum number of changed pixels, allowing for overall 1-pixel
     # shift in any direcion
@@ -332,11 +336,12 @@ def assert_image_equal(image, reference):
         for j in range(3):
             a = image[slices[i], slices[j]]
             b = ref[slices[2-i], slices[2-j]]
-            diff = (a != b).sum()
+            diff = np.any(a != b, axis=2).sum()
             if diff < min_diff:
                 min_diff = diff
     try:
-        assert min_diff <= 40
+        assert_true(min_diff <= limit,
+                    'min_diff (%s) > %s' % (min_diff, limit))
     except AssertionError:
         _save_failed_test(image, ref, reference)
         raise