statisticsPen.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. """Pen calculating area, center of mass, variance and standard-deviation,
  2. covariance and correlation, and slant, of glyph shapes."""
  3. from math import sqrt, degrees, atan
  4. from fontTools.pens.basePen import BasePen, OpenContourError
  5. from fontTools.pens.momentsPen import MomentsPen
  6. __all__ = ["StatisticsPen", "StatisticsControlPen"]
  7. class StatisticsBase:
  8. def __init__(self):
  9. self._zero()
  10. def _zero(self):
  11. self.area = 0
  12. self.meanX = 0
  13. self.meanY = 0
  14. self.varianceX = 0
  15. self.varianceY = 0
  16. self.stddevX = 0
  17. self.stddevY = 0
  18. self.covariance = 0
  19. self.correlation = 0
  20. self.slant = 0
  21. def _update(self):
  22. # XXX The variance formulas should never produce a negative value,
  23. # but due to reasons I don't understand, both of our pens do.
  24. # So we take the absolute value here.
  25. self.varianceX = abs(self.varianceX)
  26. self.varianceY = abs(self.varianceY)
  27. self.stddevX = stddevX = sqrt(self.varianceX)
  28. self.stddevY = stddevY = sqrt(self.varianceY)
  29. # Correlation(X,Y) = Covariance(X,Y) / ( stddev(X) * stddev(Y) )
  30. # https://en.wikipedia.org/wiki/Pearson_product-moment_correlation_coefficient
  31. if stddevX * stddevY == 0:
  32. correlation = float("NaN")
  33. else:
  34. # XXX The above formula should never produce a value outside
  35. # the range [-1, 1], but due to reasons I don't understand,
  36. # (probably the same issue as above), it does. So we clamp.
  37. correlation = self.covariance / (stddevX * stddevY)
  38. correlation = max(-1, min(1, correlation))
  39. self.correlation = correlation if abs(correlation) > 1e-3 else 0
  40. slant = (
  41. self.covariance / self.varianceY if self.varianceY != 0 else float("NaN")
  42. )
  43. self.slant = slant if abs(slant) > 1e-3 else 0
  44. class StatisticsPen(StatisticsBase, MomentsPen):
  45. """Pen calculating area, center of mass, variance and
  46. standard-deviation, covariance and correlation, and slant,
  47. of glyph shapes.
  48. Note that if the glyph shape is self-intersecting, the values
  49. are not correct (but well-defined). Moreover, area will be
  50. negative if contour directions are clockwise."""
  51. def __init__(self, glyphset=None):
  52. MomentsPen.__init__(self, glyphset=glyphset)
  53. StatisticsBase.__init__(self)
  54. def _closePath(self):
  55. MomentsPen._closePath(self)
  56. self._update()
  57. def _update(self):
  58. area = self.area
  59. if not area:
  60. self._zero()
  61. return
  62. # Center of mass
  63. # https://en.wikipedia.org/wiki/Center_of_mass#A_continuous_volume
  64. self.meanX = meanX = self.momentX / area
  65. self.meanY = meanY = self.momentY / area
  66. # Var(X) = E[X^2] - E[X]^2
  67. self.varianceX = self.momentXX / area - meanX * meanX
  68. self.varianceY = self.momentYY / area - meanY * meanY
  69. # Covariance(X,Y) = (E[X.Y] - E[X]E[Y])
  70. self.covariance = self.momentXY / area - meanX * meanY
  71. StatisticsBase._update(self)
  72. class StatisticsControlPen(StatisticsBase, BasePen):
  73. """Pen calculating area, center of mass, variance and
  74. standard-deviation, covariance and correlation, and slant,
  75. of glyph shapes, using the control polygon only.
  76. Note that if the glyph shape is self-intersecting, the values
  77. are not correct (but well-defined). Moreover, area will be
  78. negative if contour directions are clockwise."""
  79. def __init__(self, glyphset=None):
  80. BasePen.__init__(self, glyphset)
  81. StatisticsBase.__init__(self)
  82. self._nodes = []
  83. def _moveTo(self, pt):
  84. self._nodes.append(complex(*pt))
  85. self._startPoint = pt
  86. def _lineTo(self, pt):
  87. self._nodes.append(complex(*pt))
  88. def _qCurveToOne(self, pt1, pt2):
  89. for pt in (pt1, pt2):
  90. self._nodes.append(complex(*pt))
  91. def _curveToOne(self, pt1, pt2, pt3):
  92. for pt in (pt1, pt2, pt3):
  93. self._nodes.append(complex(*pt))
  94. def _closePath(self):
  95. p0 = self._getCurrentPoint()
  96. if p0 != self._startPoint:
  97. self._lineTo(self._startPoint)
  98. self._update()
  99. def _endPath(self):
  100. p0 = self._getCurrentPoint()
  101. if p0 != self._startPoint:
  102. raise OpenContourError("Glyph statistics not defined on open contours.")
  103. self._update()
  104. def _update(self):
  105. nodes = self._nodes
  106. n = len(nodes)
  107. # Triangle formula
  108. self.area = (
  109. sum(
  110. (p0.real * p1.imag - p1.real * p0.imag)
  111. for p0, p1 in zip(nodes, nodes[1:] + nodes[:1])
  112. )
  113. / 2
  114. )
  115. # Center of mass
  116. # https://en.wikipedia.org/wiki/Center_of_mass#A_system_of_particles
  117. sumNodes = sum(nodes)
  118. self.meanX = meanX = sumNodes.real / n
  119. self.meanY = meanY = sumNodes.imag / n
  120. if n > 1:
  121. # Var(X) = (sum[X^2] - sum[X]^2 / n) / (n - 1)
  122. # https://www.statisticshowto.com/probability-and-statistics/descriptive-statistics/sample-variance/
  123. self.varianceX = varianceX = (
  124. sum(p.real * p.real for p in nodes)
  125. - (sumNodes.real * sumNodes.real) / n
  126. ) / (n - 1)
  127. self.varianceY = varianceY = (
  128. sum(p.imag * p.imag for p in nodes)
  129. - (sumNodes.imag * sumNodes.imag) / n
  130. ) / (n - 1)
  131. # Covariance(X,Y) = (sum[X.Y] - sum[X].sum[Y] / n) / (n - 1)
  132. self.covariance = covariance = (
  133. sum(p.real * p.imag for p in nodes)
  134. - (sumNodes.real * sumNodes.imag) / n
  135. ) / (n - 1)
  136. else:
  137. self.varianceX = varianceX = 0
  138. self.varianceY = varianceY = 0
  139. self.covariance = covariance = 0
  140. StatisticsBase._update(self)
  141. def _test(glyphset, upem, glyphs, quiet=False, *, control=False):
  142. from fontTools.pens.transformPen import TransformPen
  143. from fontTools.misc.transform import Scale
  144. wght_sum = 0
  145. wght_sum_perceptual = 0
  146. wdth_sum = 0
  147. slnt_sum = 0
  148. slnt_sum_perceptual = 0
  149. for glyph_name in glyphs:
  150. glyph = glyphset[glyph_name]
  151. if control:
  152. pen = StatisticsControlPen(glyphset=glyphset)
  153. else:
  154. pen = StatisticsPen(glyphset=glyphset)
  155. transformer = TransformPen(pen, Scale(1.0 / upem))
  156. glyph.draw(transformer)
  157. area = abs(pen.area)
  158. width = glyph.width
  159. wght_sum += area
  160. wght_sum_perceptual += pen.area * width
  161. wdth_sum += width
  162. slnt_sum += pen.slant
  163. slnt_sum_perceptual += pen.slant * width
  164. if quiet:
  165. continue
  166. print()
  167. print("glyph:", glyph_name)
  168. for item in [
  169. "area",
  170. "momentX",
  171. "momentY",
  172. "momentXX",
  173. "momentYY",
  174. "momentXY",
  175. "meanX",
  176. "meanY",
  177. "varianceX",
  178. "varianceY",
  179. "stddevX",
  180. "stddevY",
  181. "covariance",
  182. "correlation",
  183. "slant",
  184. ]:
  185. print("%s: %g" % (item, getattr(pen, item)))
  186. if not quiet:
  187. print()
  188. print("font:")
  189. print("weight: %g" % (wght_sum * upem / wdth_sum))
  190. print("weight (perceptual): %g" % (wght_sum_perceptual / wdth_sum))
  191. print("width: %g" % (wdth_sum / upem / len(glyphs)))
  192. slant = slnt_sum / len(glyphs)
  193. print("slant: %g" % slant)
  194. print("slant angle: %g" % -degrees(atan(slant)))
  195. slant_perceptual = slnt_sum_perceptual / wdth_sum
  196. print("slant (perceptual): %g" % slant_perceptual)
  197. print("slant (perceptual) angle: %g" % -degrees(atan(slant_perceptual)))
  198. def main(args):
  199. """Report font glyph shape geometricsl statistics"""
  200. if args is None:
  201. import sys
  202. args = sys.argv[1:]
  203. import argparse
  204. parser = argparse.ArgumentParser(
  205. "fonttools pens.statisticsPen",
  206. description="Report font glyph shape geometricsl statistics",
  207. )
  208. parser.add_argument("font", metavar="font.ttf", help="Font file.")
  209. parser.add_argument("glyphs", metavar="glyph-name", help="Glyph names.", nargs="*")
  210. parser.add_argument(
  211. "-y",
  212. metavar="<number>",
  213. help="Face index into a collection to open. Zero based.",
  214. )
  215. parser.add_argument(
  216. "-c",
  217. "--control",
  218. action="store_true",
  219. help="Use the control-box pen instead of the Green therem.",
  220. )
  221. parser.add_argument(
  222. "-q", "--quiet", action="store_true", help="Only report font-wide statistics."
  223. )
  224. parser.add_argument(
  225. "--variations",
  226. metavar="AXIS=LOC",
  227. default="",
  228. help="List of space separated locations. A location consist in "
  229. "the name of a variation axis, followed by '=' and a number. E.g.: "
  230. "wght=700 wdth=80. The default is the location of the base master.",
  231. )
  232. options = parser.parse_args(args)
  233. glyphs = options.glyphs
  234. fontNumber = int(options.y) if options.y is not None else 0
  235. location = {}
  236. for tag_v in options.variations.split():
  237. fields = tag_v.split("=")
  238. tag = fields[0].strip()
  239. v = int(fields[1])
  240. location[tag] = v
  241. from fontTools.ttLib import TTFont
  242. font = TTFont(options.font, fontNumber=fontNumber)
  243. if not glyphs:
  244. glyphs = font.getGlyphOrder()
  245. _test(
  246. font.getGlyphSet(location=location),
  247. font["head"].unitsPerEm,
  248. glyphs,
  249. quiet=options.quiet,
  250. control=options.control,
  251. )
  252. if __name__ == "__main__":
  253. import sys
  254. main(sys.argv[1:])