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