models.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663
  1. """Variation fonts interpolation models."""
  2. from __future__ import annotations
  3. __all__ = [
  4. "normalizeValue",
  5. "normalizeLocation",
  6. "supportScalar",
  7. "piecewiseLinearMap",
  8. "VariationModel",
  9. ]
  10. from collections.abc import Mapping
  11. from typing import TYPE_CHECKING
  12. from fontTools.misc.roundTools import noRound
  13. from .errors import VariationModelError
  14. if TYPE_CHECKING:
  15. from typing import Mapping, Sequence
  16. def nonNone(lst):
  17. return [l for l in lst if l is not None]
  18. def allNone(lst):
  19. return all(l is None for l in lst)
  20. def allEqualTo(ref, lst, mapper=None):
  21. if mapper is None:
  22. return all(ref == item for item in lst)
  23. mapped = mapper(ref)
  24. return all(mapped == mapper(item) for item in lst)
  25. def allEqual(lst, mapper=None):
  26. if not lst:
  27. return True
  28. it = iter(lst)
  29. try:
  30. first = next(it)
  31. except StopIteration:
  32. return True
  33. return allEqualTo(first, it, mapper=mapper)
  34. def subList(truth, lst):
  35. assert len(truth) == len(lst)
  36. return [l for l, t in zip(lst, truth) if t]
  37. def normalizeValue(
  38. v: float, triple: Sequence[float], extrapolate: bool = False
  39. ) -> float:
  40. """Normalizes value based on a min/default/max triple.
  41. >>> normalizeValue(400, (100, 400, 900))
  42. 0.0
  43. >>> normalizeValue(100, (100, 400, 900))
  44. -1.0
  45. >>> normalizeValue(650, (100, 400, 900))
  46. 0.5
  47. """
  48. lower, default, upper = triple
  49. if not (lower <= default <= upper):
  50. raise ValueError(
  51. f"Invalid axis values, must be minimum, default, maximum: "
  52. f"{lower:3.3f}, {default:3.3f}, {upper:3.3f}"
  53. )
  54. if not extrapolate:
  55. v = max(min(v, upper), lower)
  56. if v == default or lower == upper:
  57. return 0.0
  58. if (v < default and lower != default) or (v > default and upper == default):
  59. return (v - default) / (default - lower)
  60. else:
  61. assert (v > default and upper != default) or (
  62. v < default and lower == default
  63. ), f"Ooops... v={v}, triple=({lower}, {default}, {upper})"
  64. return (v - default) / (upper - default)
  65. def normalizeLocation(
  66. location: Mapping[str, float],
  67. axes: Mapping[str, tuple[float, float, float]],
  68. extrapolate: bool = False,
  69. *,
  70. validate: bool = False,
  71. ) -> dict[str, float]:
  72. """Normalizes location based on axis min/default/max values from axes.
  73. >>> axes = {"wght": (100, 400, 900)}
  74. >>> normalizeLocation({"wght": 400}, axes)
  75. {'wght': 0.0}
  76. >>> normalizeLocation({"wght": 100}, axes)
  77. {'wght': -1.0}
  78. >>> normalizeLocation({"wght": 900}, axes)
  79. {'wght': 1.0}
  80. >>> normalizeLocation({"wght": 650}, axes)
  81. {'wght': 0.5}
  82. >>> normalizeLocation({"wght": 1000}, axes)
  83. {'wght': 1.0}
  84. >>> normalizeLocation({"wght": 0}, axes)
  85. {'wght': -1.0}
  86. >>> axes = {"wght": (0, 0, 1000)}
  87. >>> normalizeLocation({"wght": 0}, axes)
  88. {'wght': 0.0}
  89. >>> normalizeLocation({"wght": -1}, axes)
  90. {'wght': 0.0}
  91. >>> normalizeLocation({"wght": 1000}, axes)
  92. {'wght': 1.0}
  93. >>> normalizeLocation({"wght": 500}, axes)
  94. {'wght': 0.5}
  95. >>> normalizeLocation({"wght": 1001}, axes)
  96. {'wght': 1.0}
  97. >>> axes = {"wght": (0, 1000, 1000)}
  98. >>> normalizeLocation({"wght": 0}, axes)
  99. {'wght': -1.0}
  100. >>> normalizeLocation({"wght": -1}, axes)
  101. {'wght': -1.0}
  102. >>> normalizeLocation({"wght": 500}, axes)
  103. {'wght': -0.5}
  104. >>> normalizeLocation({"wght": 1000}, axes)
  105. {'wght': 0.0}
  106. >>> normalizeLocation({"wght": 1001}, axes)
  107. {'wght': 0.0}
  108. """
  109. if validate:
  110. assert set(location.keys()) <= set(axes.keys()), set(location.keys()) - set(
  111. axes.keys()
  112. )
  113. out = {}
  114. for tag, triple in axes.items():
  115. v = location.get(tag, triple[1])
  116. out[tag] = normalizeValue(v, triple, extrapolate=extrapolate)
  117. return out
  118. def supportScalar(location, support, ot=True, extrapolate=False, axisRanges=None):
  119. """Returns the scalar multiplier at location, for a master
  120. with support. If ot is True, then a peak value of zero
  121. for support of an axis means "axis does not participate". That
  122. is how OpenType Variation Font technology works.
  123. If extrapolate is True, axisRanges must be a dict that maps axis
  124. names to (axisMin, axisMax) tuples.
  125. >>> supportScalar({}, {})
  126. 1.0
  127. >>> supportScalar({'wght':.2}, {})
  128. 1.0
  129. >>> supportScalar({'wght':.2}, {'wght':(0,2,3)})
  130. 0.1
  131. >>> supportScalar({'wght':2.5}, {'wght':(0,2,4)})
  132. 0.75
  133. >>> supportScalar({'wght':2.5, 'wdth':0}, {'wght':(0,2,4), 'wdth':(-1,0,+1)})
  134. 0.75
  135. >>> supportScalar({'wght':2.5, 'wdth':.5}, {'wght':(0,2,4), 'wdth':(-1,0,+1)}, ot=False)
  136. 0.375
  137. >>> supportScalar({'wght':2.5, 'wdth':0}, {'wght':(0,2,4), 'wdth':(-1,0,+1)})
  138. 0.75
  139. >>> supportScalar({'wght':2.5, 'wdth':.5}, {'wght':(0,2,4), 'wdth':(-1,0,+1)})
  140. 0.75
  141. >>> supportScalar({'wght':3}, {'wght':(0,1,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
  142. -1.0
  143. >>> supportScalar({'wght':-1}, {'wght':(0,1,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
  144. -1.0
  145. >>> supportScalar({'wght':3}, {'wght':(0,2,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
  146. 1.5
  147. >>> supportScalar({'wght':-1}, {'wght':(0,2,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
  148. -0.5
  149. """
  150. if extrapolate and axisRanges is None:
  151. raise TypeError("axisRanges must be passed when extrapolate is True")
  152. scalar = 1.0
  153. for axis, (lower, peak, upper) in support.items():
  154. if ot:
  155. # OpenType-specific case handling
  156. if peak == 0.0:
  157. continue
  158. if lower > peak or peak > upper:
  159. continue
  160. if lower < 0.0 and upper > 0.0:
  161. continue
  162. v = location.get(axis, 0.0)
  163. else:
  164. assert axis in location
  165. v = location[axis]
  166. if v == peak:
  167. continue
  168. if extrapolate:
  169. axisMin, axisMax = axisRanges[axis]
  170. if v < axisMin and lower <= axisMin:
  171. if peak <= axisMin and peak < upper:
  172. scalar *= (v - upper) / (peak - upper)
  173. continue
  174. elif axisMin < peak:
  175. scalar *= (v - lower) / (peak - lower)
  176. continue
  177. elif axisMax < v and axisMax <= upper:
  178. if axisMax <= peak and lower < peak:
  179. scalar *= (v - lower) / (peak - lower)
  180. continue
  181. elif peak < axisMax:
  182. scalar *= (v - upper) / (peak - upper)
  183. continue
  184. if v <= lower or upper <= v:
  185. scalar = 0.0
  186. break
  187. if v < peak:
  188. scalar *= (v - lower) / (peak - lower)
  189. else: # v > peak
  190. scalar *= (v - upper) / (peak - upper)
  191. return scalar
  192. class VariationModel(object):
  193. """Locations must have the base master at the origin (ie. 0).
  194. If axis-ranges are not provided, values are assumed to be normalized to
  195. the range [-1, 1].
  196. If the extrapolate argument is set to True, then values are extrapolated
  197. outside the axis range.
  198. >>> from pprint import pprint
  199. >>> axisRanges = {'wght': (-180, +180), 'wdth': (-1, +1)}
  200. >>> locations = [ \
  201. {'wght':100}, \
  202. {'wght':-100}, \
  203. {'wght':-180}, \
  204. {'wdth':+.3}, \
  205. {'wght':+120,'wdth':.3}, \
  206. {'wght':+120,'wdth':.2}, \
  207. {}, \
  208. {'wght':+180,'wdth':.3}, \
  209. {'wght':+180}, \
  210. ]
  211. >>> model = VariationModel(locations, axisOrder=['wght'], axisRanges=axisRanges)
  212. >>> pprint(model.locations)
  213. [{},
  214. {'wght': -100},
  215. {'wght': -180},
  216. {'wght': 100},
  217. {'wght': 180},
  218. {'wdth': 0.3},
  219. {'wdth': 0.3, 'wght': 180},
  220. {'wdth': 0.3, 'wght': 120},
  221. {'wdth': 0.2, 'wght': 120}]
  222. >>> pprint(model.deltaWeights)
  223. [{},
  224. {0: 1.0},
  225. {0: 1.0},
  226. {0: 1.0},
  227. {0: 1.0},
  228. {0: 1.0},
  229. {0: 1.0, 4: 1.0, 5: 1.0},
  230. {0: 1.0, 3: 0.75, 4: 0.25, 5: 1.0, 6: 0.6666666666666666},
  231. {0: 1.0,
  232. 3: 0.75,
  233. 4: 0.25,
  234. 5: 0.6666666666666667,
  235. 6: 0.4444444444444445,
  236. 7: 0.6666666666666667}]
  237. """
  238. def __init__(
  239. self, locations, axisOrder=None, extrapolate=False, *, axisRanges=None
  240. ):
  241. locations = [{k: v for k, v in loc.items() if v != 0.0} for loc in locations]
  242. if len(set(tuple(sorted(l.items())) for l in locations)) != len(locations):
  243. raise VariationModelError("Locations must be unique.")
  244. self.origLocations = locations
  245. self.axisOrder = axisOrder if axisOrder is not None else []
  246. self.extrapolate = extrapolate
  247. if axisRanges is None:
  248. if extrapolate:
  249. axisRanges = self.computeAxisRanges(locations)
  250. else:
  251. allAxes = {axis for loc in locations for axis in loc.keys()}
  252. axisRanges = {axis: (-1, 1) for axis in allAxes}
  253. self.axisRanges = axisRanges
  254. keyFunc = self.getMasterLocationsSortKeyFunc(
  255. locations, axisOrder=self.axisOrder
  256. )
  257. self.locations = sorted(locations, key=keyFunc)
  258. # Mapping from user's master order to our master order
  259. self.mapping = [self.locations.index(l) for l in locations]
  260. self.reverseMapping = [locations.index(l) for l in self.locations]
  261. self._computeMasterSupports()
  262. self._subModels = {}
  263. def getSubModel(self, items):
  264. """Return a sub-model and the items that are not None.
  265. The sub-model is necessary for working with the subset
  266. of items when some are None.
  267. The sub-model is cached."""
  268. if None not in items:
  269. return self, items
  270. key = tuple(v is not None for v in items)
  271. subModel = self._subModels.get(key)
  272. if subModel is None:
  273. subModel = VariationModel(
  274. subList(key, self.origLocations),
  275. self.axisOrder,
  276. extrapolate=self.extrapolate,
  277. axisRanges=self.axisRanges,
  278. )
  279. self._subModels[key] = subModel
  280. return subModel, subList(key, items)
  281. @staticmethod
  282. def computeAxisRanges(locations):
  283. axisRanges = {}
  284. allAxes = {axis for loc in locations for axis in loc.keys()}
  285. for loc in locations:
  286. for axis in allAxes:
  287. value = loc.get(axis, 0)
  288. axisMin, axisMax = axisRanges.get(axis, (value, value))
  289. axisRanges[axis] = min(value, axisMin), max(value, axisMax)
  290. return axisRanges
  291. @staticmethod
  292. def getMasterLocationsSortKeyFunc(locations, axisOrder=[]):
  293. if {} not in locations:
  294. raise VariationModelError("Base master not found.")
  295. axisPoints = {}
  296. for loc in locations:
  297. if len(loc) != 1:
  298. continue
  299. axis = next(iter(loc))
  300. value = loc[axis]
  301. if axis not in axisPoints:
  302. axisPoints[axis] = {0.0}
  303. assert (
  304. value not in axisPoints[axis]
  305. ), 'Value "%s" in axisPoints["%s"] --> %s' % (value, axis, axisPoints)
  306. axisPoints[axis].add(value)
  307. def getKey(axisPoints, axisOrder):
  308. def sign(v):
  309. return -1 if v < 0 else +1 if v > 0 else 0
  310. def key(loc):
  311. rank = len(loc)
  312. onPointAxes = [
  313. axis
  314. for axis, value in loc.items()
  315. if axis in axisPoints and value in axisPoints[axis]
  316. ]
  317. orderedAxes = [axis for axis in axisOrder if axis in loc]
  318. orderedAxes.extend(
  319. [axis for axis in sorted(loc.keys()) if axis not in axisOrder]
  320. )
  321. return (
  322. rank, # First, order by increasing rank
  323. -len(onPointAxes), # Next, by decreasing number of onPoint axes
  324. tuple(
  325. axisOrder.index(axis) if axis in axisOrder else 0x10000
  326. for axis in orderedAxes
  327. ), # Next, by known axes
  328. tuple(orderedAxes), # Next, by all axes
  329. tuple(
  330. sign(loc[axis]) for axis in orderedAxes
  331. ), # Next, by signs of axis values
  332. tuple(
  333. abs(loc[axis]) for axis in orderedAxes
  334. ), # Next, by absolute value of axis values
  335. )
  336. return key
  337. ret = getKey(axisPoints, axisOrder)
  338. return ret
  339. def reorderMasters(self, master_list, mapping):
  340. # For changing the master data order without
  341. # recomputing supports and deltaWeights.
  342. new_list = [master_list[idx] for idx in mapping]
  343. self.origLocations = [self.origLocations[idx] for idx in mapping]
  344. locations = [
  345. {k: v for k, v in loc.items() if v != 0.0} for loc in self.origLocations
  346. ]
  347. self.mapping = [self.locations.index(l) for l in locations]
  348. self.reverseMapping = [locations.index(l) for l in self.locations]
  349. self._subModels = {}
  350. return new_list
  351. def _computeMasterSupports(self):
  352. self.supports = []
  353. regions = self._locationsToRegions()
  354. for i, region in enumerate(regions):
  355. locAxes = set(region.keys())
  356. # Walk over previous masters now
  357. for prev_region in regions[:i]:
  358. # Master with different axes do not participate
  359. if set(prev_region.keys()) != locAxes:
  360. continue
  361. # If it's NOT in the current box, it does not participate
  362. relevant = True
  363. for axis, (lower, peak, upper) in region.items():
  364. if not (
  365. prev_region[axis][1] == peak
  366. or lower < prev_region[axis][1] < upper
  367. ):
  368. relevant = False
  369. break
  370. if not relevant:
  371. continue
  372. # Split the box for new master; split in whatever direction
  373. # that has largest range ratio.
  374. #
  375. # For symmetry, we actually cut across multiple axes
  376. # if they have the largest, equal, ratio.
  377. # https://github.com/fonttools/fonttools/commit/7ee81c8821671157968b097f3e55309a1faa511e#commitcomment-31054804
  378. bestAxes = {}
  379. bestRatio = -1
  380. for axis in prev_region.keys():
  381. val = prev_region[axis][1]
  382. assert axis in region
  383. lower, locV, upper = region[axis]
  384. newLower, newUpper = lower, upper
  385. if val < locV:
  386. newLower = val
  387. ratio = (val - locV) / (lower - locV)
  388. elif locV < val:
  389. newUpper = val
  390. ratio = (val - locV) / (upper - locV)
  391. else: # val == locV
  392. # Can't split box in this direction.
  393. continue
  394. if ratio > bestRatio:
  395. bestAxes = {}
  396. bestRatio = ratio
  397. if ratio == bestRatio:
  398. bestAxes[axis] = (newLower, locV, newUpper)
  399. for axis, triple in bestAxes.items():
  400. region[axis] = triple
  401. self.supports.append(region)
  402. self._computeDeltaWeights()
  403. def _locationsToRegions(self):
  404. locations = self.locations
  405. axisRanges = self.axisRanges
  406. regions = []
  407. for loc in locations:
  408. region = {}
  409. for axis, locV in loc.items():
  410. if locV > 0:
  411. region[axis] = (0, locV, axisRanges[axis][1])
  412. else:
  413. region[axis] = (axisRanges[axis][0], locV, 0)
  414. regions.append(region)
  415. return regions
  416. def _computeDeltaWeights(self):
  417. self.deltaWeights = []
  418. for i, loc in enumerate(self.locations):
  419. deltaWeight = {}
  420. # Walk over previous masters now, populate deltaWeight
  421. for j, support in enumerate(self.supports[:i]):
  422. scalar = supportScalar(loc, support)
  423. if scalar:
  424. deltaWeight[j] = scalar
  425. self.deltaWeights.append(deltaWeight)
  426. def getDeltas(self, masterValues, *, round=noRound):
  427. assert len(masterValues) == len(self.deltaWeights), (
  428. len(masterValues),
  429. len(self.deltaWeights),
  430. )
  431. mapping = self.reverseMapping
  432. out = []
  433. for i, weights in enumerate(self.deltaWeights):
  434. delta = masterValues[mapping[i]]
  435. for j, weight in weights.items():
  436. if weight == 1:
  437. delta -= out[j]
  438. else:
  439. delta -= out[j] * weight
  440. out.append(round(delta))
  441. return out
  442. def getDeltasAndSupports(self, items, *, round=noRound):
  443. model, items = self.getSubModel(items)
  444. return model.getDeltas(items, round=round), model.supports
  445. def getScalars(self, loc):
  446. """Return scalars for each delta, for the given location.
  447. If interpolating many master-values at the same location,
  448. this function allows speed up by fetching the scalars once
  449. and using them with interpolateFromMastersAndScalars()."""
  450. return [
  451. supportScalar(
  452. loc, support, extrapolate=self.extrapolate, axisRanges=self.axisRanges
  453. )
  454. for support in self.supports
  455. ]
  456. def getMasterScalars(self, targetLocation):
  457. """Return multipliers for each master, for the given location.
  458. If interpolating many master-values at the same location,
  459. this function allows speed up by fetching the scalars once
  460. and using them with interpolateFromValuesAndScalars().
  461. Note that the scalars used in interpolateFromMastersAndScalars(),
  462. are *not* the same as the ones returned here. They are the result
  463. of getScalars()."""
  464. out = self.getScalars(targetLocation)
  465. for i, weights in reversed(list(enumerate(self.deltaWeights))):
  466. for j, weight in weights.items():
  467. out[j] -= out[i] * weight
  468. out = [out[self.mapping[i]] for i in range(len(out))]
  469. return out
  470. @staticmethod
  471. def interpolateFromValuesAndScalars(values, scalars):
  472. """Interpolate from values and scalars coefficients.
  473. If the values are master-values, then the scalars should be
  474. fetched from getMasterScalars().
  475. If the values are deltas, then the scalars should be fetched
  476. from getScalars(); in which case this is the same as
  477. interpolateFromDeltasAndScalars().
  478. """
  479. v = None
  480. assert len(values) == len(scalars)
  481. for value, scalar in zip(values, scalars):
  482. if not scalar:
  483. continue
  484. contribution = value * scalar
  485. if v is None:
  486. v = contribution
  487. else:
  488. v += contribution
  489. return v
  490. @staticmethod
  491. def interpolateFromDeltasAndScalars(deltas, scalars):
  492. """Interpolate from deltas and scalars fetched from getScalars()."""
  493. return VariationModel.interpolateFromValuesAndScalars(deltas, scalars)
  494. def interpolateFromDeltas(self, loc, deltas):
  495. """Interpolate from deltas, at location loc."""
  496. scalars = self.getScalars(loc)
  497. return self.interpolateFromDeltasAndScalars(deltas, scalars)
  498. def interpolateFromMasters(self, loc, masterValues, *, round=noRound):
  499. """Interpolate from master-values, at location loc."""
  500. scalars = self.getMasterScalars(loc)
  501. return self.interpolateFromValuesAndScalars(masterValues, scalars)
  502. def interpolateFromMastersAndScalars(self, masterValues, scalars, *, round=noRound):
  503. """Interpolate from master-values, and scalars fetched from
  504. getScalars(), which is useful when you want to interpolate
  505. multiple master-values with the same location."""
  506. deltas = self.getDeltas(masterValues, round=round)
  507. return self.interpolateFromDeltasAndScalars(deltas, scalars)
  508. def piecewiseLinearMap(v: float, mapping: Mapping[float, float]) -> float:
  509. keys = mapping.keys()
  510. if not keys:
  511. return v
  512. if v in keys:
  513. return mapping[v]
  514. k = min(keys)
  515. if v < k:
  516. return v + mapping[k] - k
  517. k = max(keys)
  518. if v > k:
  519. return v + mapping[k] - k
  520. # Interpolate
  521. a = max(k for k in keys if k < v)
  522. b = min(k for k in keys if k > v)
  523. va = mapping[a]
  524. vb = mapping[b]
  525. return va + (vb - va) * (v - a) / (b - a)
  526. def main(args=None):
  527. """Normalize locations on a given designspace"""
  528. from fontTools import configLogger
  529. import argparse
  530. parser = argparse.ArgumentParser(
  531. "fonttools varLib.models",
  532. description=main.__doc__,
  533. )
  534. parser.add_argument(
  535. "--loglevel",
  536. metavar="LEVEL",
  537. default="INFO",
  538. help="Logging level (defaults to INFO)",
  539. )
  540. group = parser.add_mutually_exclusive_group(required=True)
  541. group.add_argument("-d", "--designspace", metavar="DESIGNSPACE", type=str)
  542. group.add_argument(
  543. "-l",
  544. "--locations",
  545. metavar="LOCATION",
  546. nargs="+",
  547. help="Master locations as comma-separate coordinates. One must be all zeros.",
  548. )
  549. args = parser.parse_args(args)
  550. configLogger(level=args.loglevel)
  551. from pprint import pprint
  552. if args.designspace:
  553. from fontTools.designspaceLib import DesignSpaceDocument
  554. doc = DesignSpaceDocument()
  555. doc.read(args.designspace)
  556. locs = [s.location for s in doc.sources]
  557. print("Original locations:")
  558. pprint(locs)
  559. doc.normalize()
  560. print("Normalized locations:")
  561. locs = [s.location for s in doc.sources]
  562. pprint(locs)
  563. else:
  564. axes = [chr(c) for c in range(ord("A"), ord("Z") + 1)]
  565. locs = [
  566. dict(zip(axes, (float(v) for v in s.split(",")))) for s in args.locations
  567. ]
  568. model = VariationModel(locs)
  569. print("Sorted locations:")
  570. pprint(model.locations)
  571. print("Supports:")
  572. pprint(model.supports)
  573. if __name__ == "__main__":
  574. import doctest, sys
  575. if len(sys.argv) > 1:
  576. sys.exit(main())
  577. sys.exit(doctest.testmod().failed)