Extract medial tire decomposition helpers
This commit is contained in:
+8
-303
@@ -1,311 +1,16 @@
|
||||
"""Exhaustive generator for full medial tire graphs, indexed by |A(T)|.
|
||||
|
||||
Model (Definitions/Remarks 3.1--3.9 of the medial tire decompositions paper).
|
||||
|
||||
* The annular medial vertices induce a cycle A(T), the *annular cycle*
|
||||
(Theorem 3.3). Write n = |A(T)| for its number of vertices = number of
|
||||
annular faces = number of annular edges e_0,...,e_{n-1}.
|
||||
|
||||
* Each edge e_i of A(T) carries exactly one tooth (a triangle of M(T))
|
||||
whose third vertex is a non-annular apex (Definition 3.4). A tooth is an
|
||||
*up tooth* (apex in the outer region) or a *down tooth* (apex in the inner
|
||||
region). We record the tooth types as a word in {U, D}^n.
|
||||
|
||||
* No two up teeth share an apex; at most two down teeth share an apex
|
||||
(Remark 3.5). Two down teeth sharing an apex form a *bite* (Definition
|
||||
3.7). So the down teeth are partitioned into singletons and bite pairs.
|
||||
A bite pairs two down-edges and is drawn as an apex inside the disk with
|
||||
spokes to the four endpoints; bites must be mutually non-crossing, i.e.
|
||||
the bite pairs form a non-crossing (laminar) matching of the down-edges.
|
||||
The two annular edges of a bite must be non-incident (Definition 3.7):
|
||||
they share no annular vertex, so cyclically adjacent edges cannot pair.
|
||||
|
||||
* There are at least three up teeth (Remark 3.6).
|
||||
|
||||
* Bite-face condition (Remark 3.8). Let B(T) = A(T) together with the bite
|
||||
apexes. Its interior non-tooth faces are the root face plus one inner-gap
|
||||
face per bite. A singleton down tooth lies in the innermost bite enclosing
|
||||
its edge (or in the root face if none). For every interior non-tooth face
|
||||
the number of down-tooth apexes lying in that face must be 0 or at least 3.
|
||||
Equivalently: no face holds exactly one or two singleton down teeth.
|
||||
|
||||
The generator enumerates, for a given n, every (tooth word, bite matching)
|
||||
pair satisfying these properties and emits the resulting full medial tire
|
||||
graph as an explicit vertex/edge structure. Configurations may optionally be
|
||||
reduced modulo the dihedral symmetry of the cycle.
|
||||
"""
|
||||
"""Compatibility wrapper for the medial tire generator now kept in ../lib."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import itertools
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Iterator
|
||||
import os
|
||||
import sys
|
||||
|
||||
# A bite is an unordered pair of down-edge indices (i, j) with i < j.
|
||||
Bite = tuple[int, int]
|
||||
Matching = frozenset[Bite]
|
||||
PAPER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if PAPER_DIR not in sys.path:
|
||||
sys.path.insert(0, PAPER_DIR)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-crossing (laminar) matchings of the down edges.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def noncrossing_matchings(positions: tuple[int, ...]) -> tuple[Matching, ...]:
|
||||
"""All non-crossing partial matchings of ``positions`` (sorted ascending).
|
||||
|
||||
Bite pairs drawn inside the disk are non-crossing iff, read in cyclic
|
||||
order, no two pairs interleave. Cutting the cycle at the gap before the
|
||||
first edge turns this into ordinary non-crossing interval matchings, which
|
||||
obey the Catalan recursion below.
|
||||
"""
|
||||
if not positions:
|
||||
return (frozenset(),)
|
||||
head, *rest = positions
|
||||
out: list[Matching] = []
|
||||
# head left unmatched (a singleton down tooth, if its edge is down)
|
||||
for tail in noncrossing_matchings(tuple(rest)):
|
||||
out.append(tail)
|
||||
# head matched with positions[k]; the strictly-enclosed block must be
|
||||
# matched within itself to stay non-crossing.
|
||||
for k in range(1, len(positions)):
|
||||
partner = positions[k]
|
||||
inside = tuple(positions[1:k])
|
||||
outside = tuple(positions[k + 1:])
|
||||
for m_in in noncrossing_matchings(inside):
|
||||
for m_out in noncrossing_matchings(outside):
|
||||
out.append(frozenset({(head, partner)}) | m_in | m_out)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The bite-face condition (Remark 3.8).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def incident_edges(i: int, j: int, n: int) -> bool:
|
||||
"""Whether annular edges i and j share an annular vertex on the n-cycle."""
|
||||
return (j - i) % n == 1 or (i - j) % n == 1
|
||||
|
||||
|
||||
def has_incident_bite(bites: Matching, n: int) -> bool:
|
||||
"""Whether any bite pairs two incident (cyclically adjacent) edges."""
|
||||
return any(incident_edges(i, j, n) for i, j in bites)
|
||||
|
||||
|
||||
def innermost_bite(edge: int, bites: Matching) -> Bite | None:
|
||||
"""The minimal-span bite whose open interval contains ``edge``, or None."""
|
||||
enclosing = [b for b in bites if b[0] < edge < b[1]]
|
||||
if not enclosing:
|
||||
return None
|
||||
return min(enclosing, key=lambda b: b[1] - b[0])
|
||||
|
||||
|
||||
def face_singleton_counts(
|
||||
tooth_word: str, bites: Matching
|
||||
) -> dict[Bite | None, int]:
|
||||
"""Down-singletons per interior non-tooth face of B(T).
|
||||
|
||||
The key ``None`` is the root face; a bite key is that bite's inner-gap
|
||||
face. Faces with no singletons are simply absent from the result.
|
||||
"""
|
||||
matched = {edge for pair in bites for edge in pair}
|
||||
counts: dict[Bite | None, int] = defaultdict(int)
|
||||
for edge, tooth in enumerate(tooth_word):
|
||||
if tooth != "D" or edge in matched:
|
||||
continue # only singleton down teeth contribute apexes
|
||||
counts[innermost_bite(edge, bites)] += 1
|
||||
return dict(counts)
|
||||
|
||||
|
||||
def satisfies_bite_face_condition(tooth_word: str, bites: Matching) -> bool:
|
||||
"""Remark 3.8: every non-tooth face holds 0 or >=3 down-tooth apexes."""
|
||||
return all(count >= 3 for count in face_singleton_counts(tooth_word, bites).values())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The full medial tire graph as an explicit object.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FullMedialTireGraph:
|
||||
"""A full medial tire graph M(T) determined by its combinatorial data.
|
||||
|
||||
Vertices are named:
|
||||
a{k} annular medial vertex k (k = 0..n-1), forming A(T);
|
||||
u{i} apex of the up tooth on edge i;
|
||||
d{i} apex of the singleton down tooth on edge i;
|
||||
p{i}_{j} apex of the bite pairing edges i and j (i < j).
|
||||
"""
|
||||
|
||||
n: int
|
||||
tooth_word: str
|
||||
bites: Matching
|
||||
|
||||
@property
|
||||
def up_edges(self) -> tuple[int, ...]:
|
||||
return tuple(i for i, t in enumerate(self.tooth_word) if t == "U")
|
||||
|
||||
@property
|
||||
def down_edges(self) -> tuple[int, ...]:
|
||||
return tuple(i for i, t in enumerate(self.tooth_word) if t == "D")
|
||||
|
||||
@property
|
||||
def bite_edges(self) -> frozenset[int]:
|
||||
return frozenset(edge for pair in self.bites for edge in pair)
|
||||
|
||||
@property
|
||||
def singleton_down_edges(self) -> tuple[int, ...]:
|
||||
bite = self.bite_edges
|
||||
return tuple(i for i in self.down_edges if i not in bite)
|
||||
|
||||
def apex_of_edge(self, edge: int) -> str:
|
||||
if self.tooth_word[edge] == "U":
|
||||
return f"u{edge}"
|
||||
for i, j in self.bites:
|
||||
if edge in (i, j):
|
||||
return f"p{i}_{j}"
|
||||
return f"d{edge}"
|
||||
|
||||
def vertices(self) -> list[str]:
|
||||
verts = [f"a{k}" for k in range(self.n)]
|
||||
for i in self.up_edges:
|
||||
verts.append(f"u{i}")
|
||||
for i in self.singleton_down_edges:
|
||||
verts.append(f"d{i}")
|
||||
for i, j in sorted(self.bites):
|
||||
verts.append(f"p{i}_{j}")
|
||||
return verts
|
||||
|
||||
def edges(self) -> list[tuple[str, str]]:
|
||||
n = self.n
|
||||
out: list[tuple[str, str]] = []
|
||||
# annular cycle A(T)
|
||||
for k in range(n):
|
||||
out.append((f"a{k}", f"a{(k + 1) % n}"))
|
||||
# singleton teeth (up and down): two spokes each
|
||||
for i in self.up_edges:
|
||||
out += [(f"u{i}", f"a{i}"), (f"u{i}", f"a{(i + 1) % n}")]
|
||||
for i in self.singleton_down_edges:
|
||||
out += [(f"d{i}", f"a{i}"), (f"d{i}", f"a{(i + 1) % n}")]
|
||||
# bites: a shared apex with four spokes
|
||||
for i, j in sorted(self.bites):
|
||||
apex = f"p{i}_{j}"
|
||||
for edge in (i, j):
|
||||
out += [(apex, f"a{edge}"), (apex, f"a{(edge + 1) % n}")]
|
||||
return [tuple(sorted(e)) for e in out]
|
||||
|
||||
def canonical_key(self) -> tuple:
|
||||
"""Representative under the dihedral group of the cycle (rotations and
|
||||
reflections), so symmetric configurations collapse to one key."""
|
||||
n = self.n
|
||||
best: tuple | None = None
|
||||
for a in (1, -1):
|
||||
for b in range(n):
|
||||
relabel = lambda i: (a * i + b) % n
|
||||
word = [""] * n
|
||||
for i, t in enumerate(self.tooth_word):
|
||||
word[relabel(i)] = t
|
||||
mapped = tuple(sorted(
|
||||
tuple(sorted((relabel(i), relabel(j)))) for i, j in self.bites
|
||||
))
|
||||
key = (tuple(word), mapped)
|
||||
if best is None or key < best:
|
||||
best = key
|
||||
return best
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enumeration.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate(
|
||||
n: int, min_up_teeth: int = 3, dedup: bool = False
|
||||
) -> Iterator[FullMedialTireGraph]:
|
||||
"""Yield every full medial tire graph whose annular cycle has size ``n``.
|
||||
|
||||
``min_up_teeth`` defaults to 3 (Remark 3.6). With ``dedup`` set, only one
|
||||
representative per dihedral symmetry class is returned.
|
||||
"""
|
||||
seen: set[tuple] = set()
|
||||
for word_tuple in itertools.product("UD", repeat=n):
|
||||
tooth_word = "".join(word_tuple)
|
||||
if tooth_word.count("U") < min_up_teeth:
|
||||
continue
|
||||
down = tuple(i for i, t in enumerate(tooth_word) if t == "D")
|
||||
for bites in noncrossing_matchings(down):
|
||||
if has_incident_bite(bites, n):
|
||||
continue
|
||||
if not satisfies_bite_face_condition(tooth_word, bites):
|
||||
continue
|
||||
graph = FullMedialTireGraph(n=n, tooth_word=tooth_word, bites=bites)
|
||||
if dedup:
|
||||
key = graph.canonical_key()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
yield graph
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def figure_one() -> FullMedialTireGraph:
|
||||
"""The example graph of Figure 1 (Remark 3.8): 12 edges, one bite (0,6)."""
|
||||
return FullMedialTireGraph(
|
||||
n=12,
|
||||
tooth_word="DDDDDUDUUUUU", # edges 0-4,6 down; 5,7,8,9,10,11 up
|
||||
bites=frozenset({(0, 6)}),
|
||||
)
|
||||
|
||||
|
||||
def describe(graph: FullMedialTireGraph) -> str:
|
||||
counts = face_singleton_counts(graph.tooth_word, graph.bites)
|
||||
face_strs = []
|
||||
for face, c in sorted(counts.items(), key=lambda kv: (kv[0] is not None, kv[0])):
|
||||
name = "root" if face is None else f"bite{face}"
|
||||
face_strs.append(f"{name}:{c}")
|
||||
bites = ",".join(f"({i},{j})" for i, j in sorted(graph.bites)) or "-"
|
||||
faces = " ".join(face_strs) or "-"
|
||||
return (
|
||||
f"word={graph.tooth_word} up={len(graph.up_edges)} "
|
||||
f"down={len(graph.down_edges)} bites={bites} faces[{faces}]"
|
||||
)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
if args.check_figure:
|
||||
g = figure_one()
|
||||
print("Figure 1 check:")
|
||||
print(f" {describe(g)}")
|
||||
ok = satisfies_bite_face_condition(g.tooth_word, g.bites)
|
||||
print(f" satisfies Remark 3.8: {ok} (expect True; faces 4 and 0)")
|
||||
print()
|
||||
|
||||
for n in range(args.min_n, args.max_n + 1):
|
||||
graphs = list(generate(n, min_up_teeth=args.min_up, dedup=args.dedup))
|
||||
label = "classes" if args.dedup else "graphs"
|
||||
print(f"n={n}: {len(graphs)} {label}")
|
||||
if args.show:
|
||||
for g in graphs[: args.show]:
|
||||
print(f" {describe(g)}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--min-n", type=int, default=3)
|
||||
parser.add_argument("--max-n", type=int, default=8)
|
||||
parser.add_argument("--min-up", type=int, default=3, help="Remark 3.6 bound")
|
||||
parser.add_argument("--dedup", action="store_true",
|
||||
help="reduce modulo dihedral symmetry of the cycle")
|
||||
parser.add_argument("--show", type=int, default=0,
|
||||
help="print up to this many graphs per n")
|
||||
parser.add_argument("--check-figure", action="store_true",
|
||||
help="verify the Figure 1 example against Remark 3.8")
|
||||
run(parser.parse_args())
|
||||
from lib.full_medial_tire_generator import * # noqa: F401,F403
|
||||
from lib.full_medial_tire_generator import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+14
-197
@@ -19,14 +19,25 @@ is a bite.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import os
|
||||
import random
|
||||
from collections import defaultdict
|
||||
import sys
|
||||
|
||||
import networkx as nx
|
||||
import numpy as np
|
||||
import scipy.spatial
|
||||
|
||||
PAPER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if PAPER_DIR not in sys.path:
|
||||
sys.path.insert(0, PAPER_DIR)
|
||||
|
||||
from lib.medial_tire_decomposition import (
|
||||
ekey,
|
||||
extract_tread,
|
||||
medial_tire_facemodel,
|
||||
recognise,
|
||||
)
|
||||
|
||||
|
||||
def random_sphere_triangulation(n: int, seed: int) -> nx.Graph:
|
||||
"""A random maximal planar graph: convex hull of random points on S^2."""
|
||||
@@ -40,19 +51,6 @@ def random_sphere_triangulation(n: int, seed: int) -> nx.Graph:
|
||||
return g
|
||||
|
||||
|
||||
def medial_tire_facemodel(tread_faces) -> nx.Graph:
|
||||
"""Full medial tire graph M(T): the ambient tread-face model. Each tread
|
||||
face contributes a triangle on its three edge-medial-vertices; no medial
|
||||
edges between two same-boundary edges (those come from non-tread corners)."""
|
||||
Mt = nx.Graph()
|
||||
for f in tread_faces:
|
||||
es = [ekey(f[0], f[1]), ekey(f[1], f[2]), ekey(f[2], f[0])]
|
||||
Mt.add_nodes_from(es)
|
||||
for a in range(3):
|
||||
Mt.add_edge(es[a], es[(a + 1) % 3])
|
||||
return Mt
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Maximal planar graph on n vertices: stacked seed + random diagonal flips.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -89,10 +87,6 @@ def random_maximal_planar(n: int, seed: int, flips: int = 400) -> nx.Graph:
|
||||
return g
|
||||
|
||||
|
||||
def ekey(u, v):
|
||||
return (u, v) if (str(u), u) <= (str(v), v) else (v, u)
|
||||
|
||||
|
||||
def triangular_faces(g: nx.Graph):
|
||||
ok, emb = nx.check_planarity(g)
|
||||
if not ok:
|
||||
@@ -146,189 +140,12 @@ def proper_3_colorings(M: nx.Graph, limit: int):
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tread extraction from a BFS-level decomposition.
|
||||
# Classify colourings of recognised full medial tire graphs.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def extract_tread(faces, levels, d):
|
||||
"""Tread T_d: faces spanning levels {d, d+1}. Returns its edge classes."""
|
||||
tread_faces = []
|
||||
for f in faces:
|
||||
lv = [levels[x] for x in f]
|
||||
if min(lv) == d and max(lv) == d + 1:
|
||||
tread_faces.append(f)
|
||||
if not tread_faces:
|
||||
return None
|
||||
annular, up, down = set(), set(), set()
|
||||
face_of_down = defaultdict(int)
|
||||
for f in tread_faces:
|
||||
for x, y in ((f[0], f[1]), (f[1], f[2]), (f[2], f[0])):
|
||||
e = ekey(x, y)
|
||||
lx, ly = levels[x], levels[y]
|
||||
if {lx, ly} == {d, d + 1}:
|
||||
annular.add(e)
|
||||
elif lx == ly == d:
|
||||
up.add(e)
|
||||
elif lx == ly == d + 1:
|
||||
down.add(e)
|
||||
face_of_down[e] += 1
|
||||
bites = {e for e in down if face_of_down[e] == 2}
|
||||
return {
|
||||
"tread_faces": tread_faces,
|
||||
"annular": annular, "up": up, "down": down, "bites": bites,
|
||||
}
|
||||
|
||||
|
||||
def _cycle_order(sub: nx.Graph, comp):
|
||||
"""Cyclic order of a 2-regular connected component ``comp`` of ``sub``;
|
||||
None if it is not a single simple cycle of length >= 3."""
|
||||
csub = sub.subgraph(comp)
|
||||
if csub.number_of_nodes() < 3 or any(csub.degree(v) != 2 for v in csub):
|
||||
return None
|
||||
start = next(iter(comp))
|
||||
order = [start]
|
||||
prev, cur = None, start
|
||||
while True:
|
||||
nbrs = [w for w in csub.neighbors(cur) if w != prev]
|
||||
if not nbrs:
|
||||
break
|
||||
nxt = nbrs[0]
|
||||
if nxt == start:
|
||||
break
|
||||
order.append(nxt)
|
||||
prev, cur = cur, nxt
|
||||
return order if len(order) == csub.number_of_nodes() else None
|
||||
|
||||
|
||||
def annular_cycle_order(M: nx.Graph, annular: set):
|
||||
"""Cyclic order of the annular medial vertices when they induce a *single*
|
||||
cycle; None otherwise. See ``annular_cycle_components`` for the
|
||||
multi-component case."""
|
||||
sub = M.subgraph(annular)
|
||||
if not annular or not nx.is_connected(sub):
|
||||
return None
|
||||
return _cycle_order(sub, set(annular))
|
||||
|
||||
|
||||
def annular_cycle_components(M: nx.Graph, annular: set):
|
||||
"""Cyclic orders of the annular medial vertices, one per connected
|
||||
component of the annular subgraph.
|
||||
|
||||
A tread's annular frontier may split into several disjoint cycles (one per
|
||||
boundary component); each is its own full medial tire graph. Components
|
||||
that are not a single simple cycle of length >= 3 are skipped."""
|
||||
sub = M.subgraph(annular)
|
||||
orders = []
|
||||
for comp in nx.connected_components(sub):
|
||||
order = _cycle_order(sub, comp)
|
||||
if order is not None:
|
||||
orders.append(order)
|
||||
return orders
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Recognise a genuine tread as a FullMedialTireGraph and classify colourings.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
from full_medial_tire_generator import FullMedialTireGraph
|
||||
from kempe_valid_colorings import classify as kempe_classify
|
||||
|
||||
|
||||
def _linear_cut(n, bite_pairs):
|
||||
"""Rotate the cycle so the bite pairs become linear non-crossing intervals."""
|
||||
for r in range(n):
|
||||
rel = [tuple(sorted(((i - r) % n, (j - r) % n))) for i, j in bite_pairs]
|
||||
ok = True
|
||||
for a, b in rel:
|
||||
for c, d in rel:
|
||||
if (a, b) != (c, d) and (a < c < b < d or c < a < d < b):
|
||||
ok = False
|
||||
break
|
||||
if not ok:
|
||||
break
|
||||
if ok:
|
||||
return r, rel
|
||||
return None
|
||||
|
||||
|
||||
def _recognise_one(M, order, up, ann_global):
|
||||
"""Recognise a single annular cycle (given as the cyclic order of its
|
||||
medial vertices) as a ``FullMedialTireGraph``.
|
||||
|
||||
``up`` is the tread's up-edge medial-vertex set; ``ann_global`` is the full
|
||||
annular set of the tread (used to exclude annular vertices, including those
|
||||
of *other* components, when picking each cycle edge's apex). Returns
|
||||
``(g, bij)`` or None."""
|
||||
n = len(order)
|
||||
if n < 3:
|
||||
return None
|
||||
ann_set = set(order)
|
||||
|
||||
apex_of_edge = []
|
||||
for i in range(n):
|
||||
a, b = order[i], order[(i + 1) % n]
|
||||
common = [w for w in set(M.neighbors(a)) & set(M.neighbors(b))
|
||||
if w not in ann_global]
|
||||
if len(common) != 1:
|
||||
return None
|
||||
apex_of_edge.append(common[0])
|
||||
|
||||
# bite apex: serves two cycle edges (== adjacent to four annular vertices)
|
||||
apex_positions = defaultdict(list)
|
||||
for i, ap in enumerate(apex_of_edge):
|
||||
apex_positions[ap].append(i)
|
||||
bite_pairs = [tuple(sorted(positions))
|
||||
for positions in apex_positions.values() if len(positions) == 2]
|
||||
tooth = ["U" if ap in up else "D" for ap in apex_of_edge]
|
||||
|
||||
cut = _linear_cut(n, bite_pairs)
|
||||
if cut is None:
|
||||
return None
|
||||
r, rel_bites = cut
|
||||
word = [""] * n
|
||||
for i in range(n):
|
||||
word[(i - r) % n] = tooth[i]
|
||||
g = FullMedialTireGraph(n=n, tooth_word="".join(word), bites=frozenset(rel_bites))
|
||||
|
||||
# bijection fmt-name -> medial vertex
|
||||
bij = {}
|
||||
for k in range(n):
|
||||
bij[f"a{k}"] = order[(k + r) % n]
|
||||
for i in g.up_edges:
|
||||
bij[f"u{i}"] = apex_of_edge[(i + r) % n]
|
||||
for i in g.singleton_down_edges:
|
||||
bij[f"d{i}"] = apex_of_edge[(i + r) % n]
|
||||
for (i, j) in sorted(g.bites):
|
||||
bij[f"p{i}_{j}"] = apex_of_edge[(i + r) % n]
|
||||
|
||||
# verify the reconstructed graph is edge-faithful to this cycle's sub-model
|
||||
# (its annular vertices together with their tooth apexes).
|
||||
sub_nodes = ann_set | set(apex_of_edge)
|
||||
sub_edges = {ekey(*e) for e in M.subgraph(sub_nodes).edges()}
|
||||
rec_edges = {ekey(bij[u], bij[v]) for u, v in g.edges()}
|
||||
if rec_edges != sub_edges:
|
||||
return None
|
||||
return g, bij
|
||||
|
||||
|
||||
def recognise(M, tread):
|
||||
"""Recognise the tread's medial-tire structure.
|
||||
|
||||
A tread's annular frontier may be several disjoint cycles, each its own
|
||||
full medial tire graph. Returns a list of ``(FullMedialTireGraph,
|
||||
bijection fmt-name -> medial vertex)`` -- one per annular cycle component
|
||||
that recognises -- or ``[]`` if none do.
|
||||
|
||||
``M`` here is the tread-face model M(T) (cycle(s) + teeth + bites)."""
|
||||
up = set(tread["up"])
|
||||
ann_global = set(tread["annular"])
|
||||
tires = []
|
||||
for order in annular_cycle_components(M, tread["annular"]):
|
||||
rec = _recognise_one(M, order, up, ann_global)
|
||||
if rec is not None:
|
||||
tires.append(rec)
|
||||
return tires
|
||||
|
||||
|
||||
def canonical(coloring, ordered):
|
||||
remap, out = {}, []
|
||||
for v in ordered:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Reusable helpers for medial tire decomposition experiments."""
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
"""Exhaustive generator for full medial tire graphs, indexed by |A(T)|.
|
||||
|
||||
Model (Definitions/Remarks 3.1--3.9 of the medial tire decompositions paper).
|
||||
|
||||
* The annular medial vertices induce a cycle A(T), the *annular cycle*
|
||||
(Theorem 3.3). Write n = |A(T)| for its number of vertices = number of
|
||||
annular faces = number of annular edges e_0,...,e_{n-1}.
|
||||
|
||||
* Each edge e_i of A(T) carries exactly one tooth (a triangle of M(T))
|
||||
whose third vertex is a non-annular apex (Definition 3.4). A tooth is an
|
||||
*up tooth* (apex in the outer region) or a *down tooth* (apex in the inner
|
||||
region). We record the tooth types as a word in {U, D}^n.
|
||||
|
||||
* No two up teeth share an apex; at most two down teeth share an apex
|
||||
(Remark 3.5). Two down teeth sharing an apex form a *bite* (Definition
|
||||
3.7). So the down teeth are partitioned into singletons and bite pairs.
|
||||
A bite pairs two down-edges and is drawn as an apex inside the disk with
|
||||
spokes to the four endpoints; bites must be mutually non-crossing, i.e.
|
||||
the bite pairs form a non-crossing (laminar) matching of the down-edges.
|
||||
The two annular edges of a bite must be non-incident (Definition 3.7):
|
||||
they share no annular vertex, so cyclically adjacent edges cannot pair.
|
||||
|
||||
* There are at least three up teeth (Remark 3.6).
|
||||
|
||||
* Bite-face condition (Remark 3.8). Let B(T) = A(T) together with the bite
|
||||
apexes. Its interior non-tooth faces are the root face plus one inner-gap
|
||||
face per bite. A singleton down tooth lies in the innermost bite enclosing
|
||||
its edge (or in the root face if none). For every interior non-tooth face
|
||||
the number of down-tooth apexes lying in that face must be 0 or at least 3.
|
||||
Equivalently: no face holds exactly one or two singleton down teeth.
|
||||
|
||||
The generator enumerates, for a given n, every (tooth word, bite matching)
|
||||
pair satisfying these properties and emits the resulting full medial tire
|
||||
graph as an explicit vertex/edge structure. Configurations may optionally be
|
||||
reduced modulo the dihedral symmetry of the cycle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import itertools
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Iterator
|
||||
|
||||
# A bite is an unordered pair of down-edge indices (i, j) with i < j.
|
||||
Bite = tuple[int, int]
|
||||
Matching = frozenset[Bite]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-crossing (laminar) matchings of the down edges.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def noncrossing_matchings(positions: tuple[int, ...]) -> tuple[Matching, ...]:
|
||||
"""All non-crossing partial matchings of ``positions`` (sorted ascending).
|
||||
|
||||
Bite pairs drawn inside the disk are non-crossing iff, read in cyclic
|
||||
order, no two pairs interleave. Cutting the cycle at the gap before the
|
||||
first edge turns this into ordinary non-crossing interval matchings, which
|
||||
obey the Catalan recursion below.
|
||||
"""
|
||||
if not positions:
|
||||
return (frozenset(),)
|
||||
head, *rest = positions
|
||||
out: list[Matching] = []
|
||||
# head left unmatched (a singleton down tooth, if its edge is down)
|
||||
for tail in noncrossing_matchings(tuple(rest)):
|
||||
out.append(tail)
|
||||
# head matched with positions[k]; the strictly-enclosed block must be
|
||||
# matched within itself to stay non-crossing.
|
||||
for k in range(1, len(positions)):
|
||||
partner = positions[k]
|
||||
inside = tuple(positions[1:k])
|
||||
outside = tuple(positions[k + 1:])
|
||||
for m_in in noncrossing_matchings(inside):
|
||||
for m_out in noncrossing_matchings(outside):
|
||||
out.append(frozenset({(head, partner)}) | m_in | m_out)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The bite-face condition (Remark 3.8).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def incident_edges(i: int, j: int, n: int) -> bool:
|
||||
"""Whether annular edges i and j share an annular vertex on the n-cycle."""
|
||||
return (j - i) % n == 1 or (i - j) % n == 1
|
||||
|
||||
|
||||
def has_incident_bite(bites: Matching, n: int) -> bool:
|
||||
"""Whether any bite pairs two incident (cyclically adjacent) edges."""
|
||||
return any(incident_edges(i, j, n) for i, j in bites)
|
||||
|
||||
|
||||
def innermost_bite(edge: int, bites: Matching) -> Bite | None:
|
||||
"""The minimal-span bite whose open interval contains ``edge``, or None."""
|
||||
enclosing = [b for b in bites if b[0] < edge < b[1]]
|
||||
if not enclosing:
|
||||
return None
|
||||
return min(enclosing, key=lambda b: b[1] - b[0])
|
||||
|
||||
|
||||
def face_singleton_counts(
|
||||
tooth_word: str, bites: Matching
|
||||
) -> dict[Bite | None, int]:
|
||||
"""Down-singletons per interior non-tooth face of B(T).
|
||||
|
||||
The key ``None`` is the root face; a bite key is that bite's inner-gap
|
||||
face. Faces with no singletons are simply absent from the result.
|
||||
"""
|
||||
matched = {edge for pair in bites for edge in pair}
|
||||
counts: dict[Bite | None, int] = defaultdict(int)
|
||||
for edge, tooth in enumerate(tooth_word):
|
||||
if tooth != "D" or edge in matched:
|
||||
continue # only singleton down teeth contribute apexes
|
||||
counts[innermost_bite(edge, bites)] += 1
|
||||
return dict(counts)
|
||||
|
||||
|
||||
def satisfies_bite_face_condition(tooth_word: str, bites: Matching) -> bool:
|
||||
"""Remark 3.8: every non-tooth face holds 0 or >=3 down-tooth apexes."""
|
||||
return all(count >= 3 for count in face_singleton_counts(tooth_word, bites).values())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The full medial tire graph as an explicit object.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FullMedialTireGraph:
|
||||
"""A full medial tire graph M(T) determined by its combinatorial data.
|
||||
|
||||
Vertices are named:
|
||||
a{k} annular medial vertex k (k = 0..n-1), forming A(T);
|
||||
u{i} apex of the up tooth on edge i;
|
||||
d{i} apex of the singleton down tooth on edge i;
|
||||
p{i}_{j} apex of the bite pairing edges i and j (i < j).
|
||||
"""
|
||||
|
||||
n: int
|
||||
tooth_word: str
|
||||
bites: Matching
|
||||
|
||||
@property
|
||||
def up_edges(self) -> tuple[int, ...]:
|
||||
return tuple(i for i, t in enumerate(self.tooth_word) if t == "U")
|
||||
|
||||
@property
|
||||
def down_edges(self) -> tuple[int, ...]:
|
||||
return tuple(i for i, t in enumerate(self.tooth_word) if t == "D")
|
||||
|
||||
@property
|
||||
def bite_edges(self) -> frozenset[int]:
|
||||
return frozenset(edge for pair in self.bites for edge in pair)
|
||||
|
||||
@property
|
||||
def singleton_down_edges(self) -> tuple[int, ...]:
|
||||
bite = self.bite_edges
|
||||
return tuple(i for i in self.down_edges if i not in bite)
|
||||
|
||||
def apex_of_edge(self, edge: int) -> str:
|
||||
if self.tooth_word[edge] == "U":
|
||||
return f"u{edge}"
|
||||
for i, j in self.bites:
|
||||
if edge in (i, j):
|
||||
return f"p{i}_{j}"
|
||||
return f"d{edge}"
|
||||
|
||||
def vertices(self) -> list[str]:
|
||||
verts = [f"a{k}" for k in range(self.n)]
|
||||
for i in self.up_edges:
|
||||
verts.append(f"u{i}")
|
||||
for i in self.singleton_down_edges:
|
||||
verts.append(f"d{i}")
|
||||
for i, j in sorted(self.bites):
|
||||
verts.append(f"p{i}_{j}")
|
||||
return verts
|
||||
|
||||
def edges(self) -> list[tuple[str, str]]:
|
||||
n = self.n
|
||||
out: list[tuple[str, str]] = []
|
||||
# annular cycle A(T)
|
||||
for k in range(n):
|
||||
out.append((f"a{k}", f"a{(k + 1) % n}"))
|
||||
# singleton teeth (up and down): two spokes each
|
||||
for i in self.up_edges:
|
||||
out += [(f"u{i}", f"a{i}"), (f"u{i}", f"a{(i + 1) % n}")]
|
||||
for i in self.singleton_down_edges:
|
||||
out += [(f"d{i}", f"a{i}"), (f"d{i}", f"a{(i + 1) % n}")]
|
||||
# bites: a shared apex with four spokes
|
||||
for i, j in sorted(self.bites):
|
||||
apex = f"p{i}_{j}"
|
||||
for edge in (i, j):
|
||||
out += [(apex, f"a{edge}"), (apex, f"a{(edge + 1) % n}")]
|
||||
return [tuple(sorted(e)) for e in out]
|
||||
|
||||
def canonical_key(self) -> tuple:
|
||||
"""Representative under the dihedral group of the cycle (rotations and
|
||||
reflections), so symmetric configurations collapse to one key."""
|
||||
n = self.n
|
||||
best: tuple | None = None
|
||||
for a in (1, -1):
|
||||
for b in range(n):
|
||||
relabel = lambda i: (a * i + b) % n
|
||||
word = [""] * n
|
||||
for i, t in enumerate(self.tooth_word):
|
||||
word[relabel(i)] = t
|
||||
mapped = tuple(sorted(
|
||||
tuple(sorted((relabel(i), relabel(j)))) for i, j in self.bites
|
||||
))
|
||||
key = (tuple(word), mapped)
|
||||
if best is None or key < best:
|
||||
best = key
|
||||
return best
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enumeration.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate(
|
||||
n: int, min_up_teeth: int = 3, dedup: bool = False
|
||||
) -> Iterator[FullMedialTireGraph]:
|
||||
"""Yield every full medial tire graph whose annular cycle has size ``n``.
|
||||
|
||||
``min_up_teeth`` defaults to 3 (Remark 3.6). With ``dedup`` set, only one
|
||||
representative per dihedral symmetry class is returned.
|
||||
"""
|
||||
seen: set[tuple] = set()
|
||||
for word_tuple in itertools.product("UD", repeat=n):
|
||||
tooth_word = "".join(word_tuple)
|
||||
if tooth_word.count("U") < min_up_teeth:
|
||||
continue
|
||||
down = tuple(i for i, t in enumerate(tooth_word) if t == "D")
|
||||
for bites in noncrossing_matchings(down):
|
||||
if has_incident_bite(bites, n):
|
||||
continue
|
||||
if not satisfies_bite_face_condition(tooth_word, bites):
|
||||
continue
|
||||
graph = FullMedialTireGraph(n=n, tooth_word=tooth_word, bites=bites)
|
||||
if dedup:
|
||||
key = graph.canonical_key()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
yield graph
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def figure_one() -> FullMedialTireGraph:
|
||||
"""The example graph of Figure 1 (Remark 3.8): 12 edges, one bite (0,6)."""
|
||||
return FullMedialTireGraph(
|
||||
n=12,
|
||||
tooth_word="DDDDDUDUUUUU", # edges 0-4,6 down; 5,7,8,9,10,11 up
|
||||
bites=frozenset({(0, 6)}),
|
||||
)
|
||||
|
||||
|
||||
def describe(graph: FullMedialTireGraph) -> str:
|
||||
counts = face_singleton_counts(graph.tooth_word, graph.bites)
|
||||
face_strs = []
|
||||
for face, c in sorted(counts.items(), key=lambda kv: (kv[0] is not None, kv[0])):
|
||||
name = "root" if face is None else f"bite{face}"
|
||||
face_strs.append(f"{name}:{c}")
|
||||
bites = ",".join(f"({i},{j})" for i, j in sorted(graph.bites)) or "-"
|
||||
faces = " ".join(face_strs) or "-"
|
||||
return (
|
||||
f"word={graph.tooth_word} up={len(graph.up_edges)} "
|
||||
f"down={len(graph.down_edges)} bites={bites} faces[{faces}]"
|
||||
)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
if args.check_figure:
|
||||
g = figure_one()
|
||||
print("Figure 1 check:")
|
||||
print(f" {describe(g)}")
|
||||
ok = satisfies_bite_face_condition(g.tooth_word, g.bites)
|
||||
print(f" satisfies Remark 3.8: {ok} (expect True; faces 4 and 0)")
|
||||
print()
|
||||
|
||||
for n in range(args.min_n, args.max_n + 1):
|
||||
graphs = list(generate(n, min_up_teeth=args.min_up, dedup=args.dedup))
|
||||
label = "classes" if args.dedup else "graphs"
|
||||
print(f"n={n}: {len(graphs)} {label}")
|
||||
if args.show:
|
||||
for g in graphs[: args.show]:
|
||||
print(f" {describe(g)}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--min-n", type=int, default=3)
|
||||
parser.add_argument("--max-n", type=int, default=8)
|
||||
parser.add_argument("--min-up", type=int, default=3, help="Remark 3.6 bound")
|
||||
parser.add_argument("--dedup", action="store_true",
|
||||
help="reduce modulo dihedral symmetry of the cycle")
|
||||
parser.add_argument("--show", type=int, default=0,
|
||||
help="print up to this many graphs per n")
|
||||
parser.add_argument("--check-figure", action="store_true",
|
||||
help="verify the Figure 1 example against Remark 3.8")
|
||||
run(parser.parse_args())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
"""Medial tire decomposition helpers for plane triangulation experiments."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
import networkx as nx
|
||||
|
||||
from .full_medial_tire_generator import FullMedialTireGraph
|
||||
|
||||
|
||||
def ekey(u, v):
|
||||
return (u, v) if (str(u), u) <= (str(v), v) else (v, u)
|
||||
|
||||
|
||||
def medial_tire_facemodel(tread_faces) -> nx.Graph:
|
||||
"""Build the ambient tread-face model of a full medial tire graph M(T)."""
|
||||
mt = nx.Graph()
|
||||
for f in tread_faces:
|
||||
es = [ekey(f[0], f[1]), ekey(f[1], f[2]), ekey(f[2], f[0])]
|
||||
mt.add_nodes_from(es)
|
||||
for a in range(3):
|
||||
mt.add_edge(es[a], es[(a + 1) % 3])
|
||||
return mt
|
||||
|
||||
|
||||
def extract_tread(faces, levels, d):
|
||||
"""Tread T_d: faces spanning levels {d, d+1}. Return its edge classes."""
|
||||
tread_faces = []
|
||||
for f in faces:
|
||||
lv = [levels[x] for x in f]
|
||||
if min(lv) == d and max(lv) == d + 1:
|
||||
tread_faces.append(f)
|
||||
if not tread_faces:
|
||||
return None
|
||||
|
||||
annular, up, down = set(), set(), set()
|
||||
face_of_down = defaultdict(int)
|
||||
for f in tread_faces:
|
||||
for x, y in ((f[0], f[1]), (f[1], f[2]), (f[2], f[0])):
|
||||
e = ekey(x, y)
|
||||
lx, ly = levels[x], levels[y]
|
||||
if {lx, ly} == {d, d + 1}:
|
||||
annular.add(e)
|
||||
elif lx == ly == d:
|
||||
up.add(e)
|
||||
elif lx == ly == d + 1:
|
||||
down.add(e)
|
||||
face_of_down[e] += 1
|
||||
|
||||
bites = {e for e in down if face_of_down[e] == 2}
|
||||
return {
|
||||
"tread_faces": tread_faces,
|
||||
"annular": annular,
|
||||
"up": up,
|
||||
"down": down,
|
||||
"bites": bites,
|
||||
}
|
||||
|
||||
|
||||
def _cycle_order(sub: nx.Graph, comp):
|
||||
"""Cyclic order of a simple 2-regular component, or None."""
|
||||
csub = sub.subgraph(comp)
|
||||
if csub.number_of_nodes() < 3 or any(csub.degree(v) != 2 for v in csub):
|
||||
return None
|
||||
start = next(iter(comp))
|
||||
order = [start]
|
||||
prev, cur = None, start
|
||||
while True:
|
||||
nbrs = [w for w in csub.neighbors(cur) if w != prev]
|
||||
if not nbrs:
|
||||
break
|
||||
nxt = nbrs[0]
|
||||
if nxt == start:
|
||||
break
|
||||
order.append(nxt)
|
||||
prev, cur = cur, nxt
|
||||
return order if len(order) == csub.number_of_nodes() else None
|
||||
|
||||
|
||||
def annular_cycle_order(M: nx.Graph, annular: set):
|
||||
"""Cyclic order of annular medial vertices when they induce one cycle."""
|
||||
sub = M.subgraph(annular)
|
||||
if not annular or not nx.is_connected(sub):
|
||||
return None
|
||||
return _cycle_order(sub, set(annular))
|
||||
|
||||
|
||||
def annular_cycle_components(M: nx.Graph, annular: set):
|
||||
"""Cyclic orders of annular medial vertices, one per cycle component."""
|
||||
sub = M.subgraph(annular)
|
||||
orders = []
|
||||
for comp in nx.connected_components(sub):
|
||||
order = _cycle_order(sub, comp)
|
||||
if order is not None:
|
||||
orders.append(order)
|
||||
return orders
|
||||
|
||||
|
||||
def _linear_cut(n, bite_pairs):
|
||||
"""Rotate the cycle so bite pairs become linear non-crossing intervals."""
|
||||
for r in range(n):
|
||||
rel = [tuple(sorted(((i - r) % n, (j - r) % n))) for i, j in bite_pairs]
|
||||
ok = True
|
||||
for a, b in rel:
|
||||
for c, d in rel:
|
||||
if (a, b) != (c, d) and (a < c < b < d or c < a < d < b):
|
||||
ok = False
|
||||
break
|
||||
if not ok:
|
||||
break
|
||||
if ok:
|
||||
return r, rel
|
||||
return None
|
||||
|
||||
|
||||
def _recognise_one(M, order, up, ann_global):
|
||||
"""Recognise one annular cycle as a FullMedialTireGraph."""
|
||||
n = len(order)
|
||||
if n < 3:
|
||||
return None
|
||||
ann_set = set(order)
|
||||
|
||||
apex_of_edge = []
|
||||
for i in range(n):
|
||||
a, b = order[i], order[(i + 1) % n]
|
||||
common = [
|
||||
w for w in set(M.neighbors(a)) & set(M.neighbors(b))
|
||||
if w not in ann_global
|
||||
]
|
||||
if len(common) != 1:
|
||||
return None
|
||||
apex_of_edge.append(common[0])
|
||||
|
||||
apex_positions = defaultdict(list)
|
||||
for i, ap in enumerate(apex_of_edge):
|
||||
apex_positions[ap].append(i)
|
||||
bite_pairs = [
|
||||
tuple(sorted(positions))
|
||||
for positions in apex_positions.values()
|
||||
if len(positions) == 2
|
||||
]
|
||||
tooth = ["U" if ap in up else "D" for ap in apex_of_edge]
|
||||
|
||||
cut = _linear_cut(n, bite_pairs)
|
||||
if cut is None:
|
||||
return None
|
||||
r, rel_bites = cut
|
||||
word = [""] * n
|
||||
for i in range(n):
|
||||
word[(i - r) % n] = tooth[i]
|
||||
graph = FullMedialTireGraph(
|
||||
n=n, tooth_word="".join(word), bites=frozenset(rel_bites)
|
||||
)
|
||||
|
||||
bij = {}
|
||||
for k in range(n):
|
||||
bij[f"a{k}"] = order[(k + r) % n]
|
||||
for i in graph.up_edges:
|
||||
bij[f"u{i}"] = apex_of_edge[(i + r) % n]
|
||||
for i in graph.singleton_down_edges:
|
||||
bij[f"d{i}"] = apex_of_edge[(i + r) % n]
|
||||
for i, j in sorted(graph.bites):
|
||||
bij[f"p{i}_{j}"] = apex_of_edge[(i + r) % n]
|
||||
|
||||
sub_nodes = ann_set | set(apex_of_edge)
|
||||
sub_edges = {ekey(*e) for e in M.subgraph(sub_nodes).edges()}
|
||||
rec_edges = {ekey(bij[u], bij[v]) for u, v in graph.edges()}
|
||||
if rec_edges != sub_edges:
|
||||
return None
|
||||
return graph, bij
|
||||
|
||||
|
||||
def recognise(M, tread):
|
||||
"""Recognise the tread's medial-tire structure.
|
||||
|
||||
A tread's annular frontier may be several disjoint cycles, each its own
|
||||
full medial tire graph. Returns one ``(FullMedialTireGraph, bijection)``
|
||||
pair per annular cycle component that recognises.
|
||||
"""
|
||||
up = set(tread["up"])
|
||||
ann_global = set(tread["annular"])
|
||||
tires = []
|
||||
for order in annular_cycle_components(M, tread["annular"]):
|
||||
rec = _recognise_one(M, order, up, ann_global)
|
||||
if rec is not None:
|
||||
tires.append(rec)
|
||||
return tires
|
||||
Reference in New Issue
Block a user