| """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="<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:]) |