"""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): # XXX The variance formulas should never produce a negative value, # but due to reasons I don't understand, both of our pens do. # So we take the absolute value here. self.varianceX = abs(self.varianceX) self.varianceY = abs(self.varianceY) self.stddevX = stddevX = sqrt(self.varianceX) self.stddevY = stddevY = sqrt(self.varianceY) # Correlation(X,Y) = Covariance(X,Y) / ( stddev(X) * stddev(Y) ) # https://en.wikipedia.org/wiki/Pearson_product-moment_correlation_coefficient if stddevX * stddevY == 0: correlation = float("NaN") else: # XXX The above formula should never produce a value outside # the range [-1, 1], but due to reasons I don't understand, # (probably the same issue as above), it does. So we clamp. 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 # Center of mass # https://en.wikipedia.org/wiki/Center_of_mass#A_continuous_volume self.meanX = meanX = self.momentX / area self.meanY = meanY = self.momentY / area # Var(X) = E[X^2] - E[X]^2 self.varianceX = self.momentXX / area - meanX * meanX self.varianceY = self.momentYY / area - meanY * meanY # Covariance(X,Y) = (E[X.Y] - E[X]E[Y]) 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) # Triangle formula self.area = ( sum( (p0.real * p1.imag - p1.real * p0.imag) for p0, p1 in zip(nodes, nodes[1:] + nodes[:1]) ) / 2 ) # Center of mass # https://en.wikipedia.org/wiki/Center_of_mass#A_system_of_particles sumNodes = sum(nodes) self.meanX = meanX = sumNodes.real / n self.meanY = meanY = sumNodes.imag / n if n > 1: # Var(X) = (sum[X^2] - sum[X]^2 / n) / (n - 1) # https://www.statisticshowto.com/probability-and-statistics/descriptive-statistics/sample-variance/ 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) # Covariance(X,Y) = (sum[X.Y] - sum[X].sum[Y] / 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="", 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:])