xref: /aosp_15_r20/external/fonttools/Lib/fontTools/pens/statisticsPen.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
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