|
"""Pen calculating area, center of mass, variance and standard-deviation, |
|
covariance and correlation, and slant, of glyph shapes.""" |
|
|
|
from math import sqrt, degrees, atan |
|
from fontTools.pens.basePen import BasePen, OpenContourError |
|
from fontTools.pens.momentsPen import MomentsPen |
|
|
|
__all__ = ["StatisticsPen", "StatisticsControlPen"] |
|
|
|
|
|
class StatisticsBase: |
|
def __init__(self): |
|
self._zero() |
|
|
|
def _zero(self): |
|
self.area = 0 |
|
self.meanX = 0 |
|
self.meanY = 0 |
|
self.varianceX = 0 |
|
self.varianceY = 0 |
|
self.stddevX = 0 |
|
self.stddevY = 0 |
|
self.covariance = 0 |
|
self.correlation = 0 |
|
self.slant = 0 |
|
|
|
def _update(self): |
|
|
|
|
|
|
|
self.varianceX = abs(self.varianceX) |
|
self.varianceY = abs(self.varianceY) |
|
|
|
self.stddevX = stddevX = sqrt(self.varianceX) |
|
self.stddevY = stddevY = sqrt(self.varianceY) |
|
|
|
|
|
|
|
if stddevX * stddevY == 0: |
|
correlation = float("NaN") |
|
else: |
|
|
|
|
|
|
|
correlation = self.covariance / (stddevX * stddevY) |
|
correlation = max(-1, min(1, correlation)) |
|
self.correlation = correlation if abs(correlation) > 1e-3 else 0 |
|
|
|
slant = ( |
|
self.covariance / self.varianceY if self.varianceY != 0 else float("NaN") |
|
) |
|
self.slant = slant if abs(slant) > 1e-3 else 0 |
|
|
|
|
|
class StatisticsPen(StatisticsBase, MomentsPen): |
|
"""Pen calculating area, center of mass, variance and |
|
standard-deviation, covariance and correlation, and slant, |
|
of glyph shapes. |
|
|
|
Note that if the glyph shape is self-intersecting, the values |
|
are not correct (but well-defined). Moreover, area will be |
|
negative if contour directions are clockwise.""" |
|
|
|
def __init__(self, glyphset=None): |
|
MomentsPen.__init__(self, glyphset=glyphset) |
|
StatisticsBase.__init__(self) |
|
|
|
def _closePath(self): |
|
MomentsPen._closePath(self) |
|
self._update() |
|
|
|
def _update(self): |
|
area = self.area |
|
if not area: |
|
self._zero() |
|
return |
|
|
|
|
|
|
|
self.meanX = meanX = self.momentX / area |
|
self.meanY = meanY = self.momentY / area |
|
|
|
|
|
self.varianceX = self.momentXX / area - meanX * meanX |
|
self.varianceY = self.momentYY / area - meanY * meanY |
|
|
|
|
|
self.covariance = self.momentXY / area - meanX * meanY |
|
|
|
StatisticsBase._update(self) |
|
|
|
|
|
class StatisticsControlPen(StatisticsBase, BasePen): |
|
"""Pen calculating area, center of mass, variance and |
|
standard-deviation, covariance and correlation, and slant, |
|
of glyph shapes, using the control polygon only. |
|
|
|
Note that if the glyph shape is self-intersecting, the values |
|
are not correct (but well-defined). Moreover, area will be |
|
negative if contour directions are clockwise.""" |
|
|
|
def __init__(self, glyphset=None): |
|
BasePen.__init__(self, glyphset) |
|
StatisticsBase.__init__(self) |
|
self._nodes = [] |
|
|
|
def _moveTo(self, pt): |
|
self._nodes.append(complex(*pt)) |
|
|
|
def _lineTo(self, pt): |
|
self._nodes.append(complex(*pt)) |
|
|
|
def _qCurveToOne(self, pt1, pt2): |
|
for pt in (pt1, pt2): |
|
self._nodes.append(complex(*pt)) |
|
|
|
def _curveToOne(self, pt1, pt2, pt3): |
|
for pt in (pt1, pt2, pt3): |
|
self._nodes.append(complex(*pt)) |
|
|
|
def _closePath(self): |
|
self._update() |
|
|
|
def _endPath(self): |
|
p0 = self._getCurrentPoint() |
|
if p0 != self._startPoint: |
|
raise OpenContourError("Glyph statistics not defined on open contours.") |
|
|
|
def _update(self): |
|
nodes = self._nodes |
|
n = len(nodes) |
|
|
|
|
|
self.area = ( |
|
sum( |
|
(p0.real * p1.imag - p1.real * p0.imag) |
|
for p0, p1 in zip(nodes, nodes[1:] + nodes[:1]) |
|
) |
|
/ 2 |
|
) |
|
|
|
|
|
|
|
sumNodes = sum(nodes) |
|
self.meanX = meanX = sumNodes.real / n |
|
self.meanY = meanY = sumNodes.imag / n |
|
|
|
if n > 1: |
|
|
|
|
|
self.varianceX = varianceX = ( |
|
sum(p.real * p.real for p in nodes) |
|
- (sumNodes.real * sumNodes.real) / n |
|
) / (n - 1) |
|
self.varianceY = varianceY = ( |
|
sum(p.imag * p.imag for p in nodes) |
|
- (sumNodes.imag * sumNodes.imag) / n |
|
) / (n - 1) |
|
|
|
|
|
self.covariance = covariance = ( |
|
sum(p.real * p.imag for p in nodes) |
|
- (sumNodes.real * sumNodes.imag) / n |
|
) / (n - 1) |
|
else: |
|
self.varianceX = varianceX = 0 |
|
self.varianceY = varianceY = 0 |
|
self.covariance = covariance = 0 |
|
|
|
StatisticsBase._update(self) |
|
|
|
|
|
def _test(glyphset, upem, glyphs, quiet=False, *, control=False): |
|
from fontTools.pens.transformPen import TransformPen |
|
from fontTools.misc.transform import Scale |
|
|
|
wght_sum = 0 |
|
wght_sum_perceptual = 0 |
|
wdth_sum = 0 |
|
slnt_sum = 0 |
|
slnt_sum_perceptual = 0 |
|
for glyph_name in glyphs: |
|
glyph = glyphset[glyph_name] |
|
if control: |
|
pen = StatisticsControlPen(glyphset=glyphset) |
|
else: |
|
pen = StatisticsPen(glyphset=glyphset) |
|
transformer = TransformPen(pen, Scale(1.0 / upem)) |
|
glyph.draw(transformer) |
|
|
|
area = abs(pen.area) |
|
width = glyph.width |
|
wght_sum += area |
|
wght_sum_perceptual += pen.area * width |
|
wdth_sum += width |
|
slnt_sum += pen.slant |
|
slnt_sum_perceptual += pen.slant * width |
|
|
|
if quiet: |
|
continue |
|
|
|
print() |
|
print("glyph:", glyph_name) |
|
|
|
for item in [ |
|
"area", |
|
"momentX", |
|
"momentY", |
|
"momentXX", |
|
"momentYY", |
|
"momentXY", |
|
"meanX", |
|
"meanY", |
|
"varianceX", |
|
"varianceY", |
|
"stddevX", |
|
"stddevY", |
|
"covariance", |
|
"correlation", |
|
"slant", |
|
]: |
|
print("%s: %g" % (item, getattr(pen, item))) |
|
|
|
if not quiet: |
|
print() |
|
print("font:") |
|
|
|
print("weight: %g" % (wght_sum * upem / wdth_sum)) |
|
print("weight (perceptual): %g" % (wght_sum_perceptual / wdth_sum)) |
|
print("width: %g" % (wdth_sum / upem / len(glyphs))) |
|
slant = slnt_sum / len(glyphs) |
|
print("slant: %g" % slant) |
|
print("slant angle: %g" % -degrees(atan(slant))) |
|
slant_perceptual = slnt_sum_perceptual / wdth_sum |
|
print("slant (perceptual): %g" % slant_perceptual) |
|
print("slant (perceptual) angle: %g" % -degrees(atan(slant_perceptual))) |
|
|
|
|
|
def main(args): |
|
"""Report font glyph shape geometricsl statistics""" |
|
|
|
if args is None: |
|
import sys |
|
|
|
args = sys.argv[1:] |
|
|
|
import argparse |
|
|
|
parser = argparse.ArgumentParser( |
|
"fonttools pens.statisticsPen", |
|
description="Report font glyph shape geometricsl statistics", |
|
) |
|
parser.add_argument("font", metavar="font.ttf", help="Font file.") |
|
parser.add_argument("glyphs", metavar="glyph-name", help="Glyph names.", nargs="*") |
|
parser.add_argument( |
|
"-y", |
|
metavar="<number>", |
|
help="Face index into a collection to open. Zero based.", |
|
) |
|
parser.add_argument( |
|
"-c", |
|
"--control", |
|
action="store_true", |
|
help="Use the control-box pen instead of the Green therem.", |
|
) |
|
parser.add_argument( |
|
"-q", "--quiet", action="store_true", help="Only report font-wide statistics." |
|
) |
|
parser.add_argument( |
|
"--variations", |
|
metavar="AXIS=LOC", |
|
default="", |
|
help="List of space separated locations. A location consist in " |
|
"the name of a variation axis, followed by '=' and a number. E.g.: " |
|
"wght=700 wdth=80. The default is the location of the base master.", |
|
) |
|
|
|
options = parser.parse_args(args) |
|
|
|
glyphs = options.glyphs |
|
fontNumber = int(options.y) if options.y is not None else 0 |
|
|
|
location = {} |
|
for tag_v in options.variations.split(): |
|
fields = tag_v.split("=") |
|
tag = fields[0].strip() |
|
v = int(fields[1]) |
|
location[tag] = v |
|
|
|
from fontTools.ttLib import TTFont |
|
|
|
font = TTFont(options.font, fontNumber=fontNumber) |
|
if not glyphs: |
|
glyphs = font.getGlyphOrder() |
|
_test( |
|
font.getGlyphSet(location=location), |
|
font["head"].unitsPerEm, |
|
glyphs, |
|
quiet=options.quiet, |
|
control=options.control, |
|
) |
|
|
|
|
|
if __name__ == "__main__": |
|
import sys |
|
|
|
main(sys.argv[1:]) |
|
|