987 lines
40 KiB
Python
987 lines
40 KiB
Python
# coding=utf-8
|
|
"""
|
|
Noyau de calcul de l'extension « Gray Iso-Layers », sans dependance a inkex.
|
|
|
|
Testable avec pytest seul et reutilisable hors Inkscape (scripts, schema
|
|
des parametres). Seule dependance : numpy (fourni avec Inkscape).
|
|
|
|
Conventions :
|
|
- un champ (`field`) est un tableau numpy 2D de gris, 0 = noir, 1 = blanc,
|
|
indexe [ligne, colonne] ;
|
|
- un anneau est un tableau numpy (n, 2) de points (x, y), ferme implicitement ;
|
|
un contour (`rings`) est une liste d'anneaux, les trous suivent la regle
|
|
pair-impair ;
|
|
- les anneaux sont exprimes en « echantillons » : x de 0 a largeur - 1, y de 0
|
|
a hauteur - 1, les echantillons du bord etant poses sur le bord de l'image ;
|
|
- l'axe y est oriente vers le bas (repere SVG).
|
|
"""
|
|
|
|
import numpy as np
|
|
|
|
# Valeur de la bordure ajoutee autour du champ : assez grande pour que les
|
|
# contours se referment exactement sur les echantillons du bord.
|
|
_OUTSIDE = -1e12
|
|
|
|
# Segments de contour par cas du « marching squares ». Le cas est le nombre
|
|
# 8.HG + 4.HD + 2.BD + 1.BG (coins dans la region) ; chaque segment relie deux
|
|
# aretes de la cellule (T haut, R droite, B bas, L gauche). Les cas 5 et 10
|
|
# (selles) sont traites a part.
|
|
_CASES = {
|
|
1: ("LB",), 2: ("BR",), 3: ("LR",), 4: ("TR",), 6: ("TB",), 7: ("TL",),
|
|
8: ("TL",), 9: ("TB",), 11: ("TR",), 12: ("LR",), 13: ("BR",), 14: ("LB",),
|
|
}
|
|
_SADDLES = {
|
|
# cas : (segments si le centre est dans la region, segments sinon)
|
|
5: (("TL", "BR"), ("TR", "LB")),
|
|
10: (("TR", "LB"), ("TL", "BR")),
|
|
}
|
|
|
|
|
|
def parse_color(value, default=("#b3b3b3", 1.0)):
|
|
"""Couleur Inkscape (entier RGBA decimal ou 0x..., ou #rrggbb[aa]).
|
|
|
|
Renvoie (couleur CSS #rrggbb, opacite entre 0 et 1). Le parametre
|
|
« color » d'Inkscape arrive sous forme d'entier RGBA ; on le decode
|
|
nous-memes pour ne pas dependre de l'API couleur d'inkex, qui a change
|
|
entre les versions 1.x.
|
|
"""
|
|
text = str(value).strip()
|
|
try:
|
|
if text.startswith("#"):
|
|
digits = text[1:]
|
|
if len(digits) == 3:
|
|
digits = "".join(c * 2 for c in digits)
|
|
if len(digits) == 6:
|
|
digits += "ff"
|
|
if len(digits) != 8:
|
|
return default
|
|
number = int(digits, 16)
|
|
else:
|
|
number = int(text, 0)
|
|
except ValueError:
|
|
return default
|
|
number &= 0xFFFFFFFF
|
|
red, green, blue = (number >> 24) & 255, (number >> 16) & 255, (number >> 8) & 255
|
|
alpha = (number & 255) / 255.0
|
|
return "#{:02x}{:02x}{:02x}".format(red, green, blue), round(alpha, 4)
|
|
|
|
|
|
def gray_to_hex(gray):
|
|
"""Couleur CSS #rrggbb d'un gris entre 0 (noir) et 1 (blanc)."""
|
|
value = int(round(255 * min(1.0, max(0.0, float(gray)))))
|
|
return "#{0:02x}{0:02x}{0:02x}".format(value)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Preparation du champ
|
|
# --------------------------------------------------------------------------
|
|
|
|
def gaussian_blur(field, sigma):
|
|
"""Flou gaussien separable d'ecart-type `sigma` (en echantillons).
|
|
|
|
Les bords sont prolonges par repetition, pour ne pas les assombrir.
|
|
"""
|
|
field = np.asarray(field, dtype=float)
|
|
if sigma <= 0:
|
|
return field.copy()
|
|
radius = max(1, int(np.ceil(3 * sigma)))
|
|
taps = np.arange(-radius, radius + 1)
|
|
kernel = np.exp(-0.5 * (taps / float(sigma)) ** 2)
|
|
kernel /= kernel.sum()
|
|
|
|
result = field
|
|
for axis in (0, 1):
|
|
pad = [(0, 0), (0, 0)]
|
|
pad[axis] = (radius, radius)
|
|
padded = np.pad(result, pad, mode="edge")
|
|
size = result.shape[axis]
|
|
blurred = np.zeros_like(result)
|
|
for k, weight in enumerate(kernel):
|
|
window = [slice(None), slice(None)]
|
|
window[axis] = slice(k, k + size)
|
|
blurred += weight * padded[tuple(window)]
|
|
result = blurred
|
|
return result
|
|
|
|
|
|
def box_blur(field, radius):
|
|
"""Moyenne sur une fenetre carree de cote 2 * radius + 1, bords repetes."""
|
|
field = np.asarray(field, dtype=float)
|
|
radius = int(radius)
|
|
if radius < 1:
|
|
return field.copy()
|
|
size = 2 * radius + 1
|
|
total = np.cumsum(np.cumsum(np.pad(field, radius, mode="edge"), axis=0), axis=1)
|
|
total = np.pad(total, ((1, 0), (1, 0)))
|
|
return (total[size:, size:] - total[:-size, size:]
|
|
- total[size:, :-size] + total[:-size, :-size]) / float(size * size)
|
|
|
|
|
|
def edge_preserving_blur(field, radius, contrast=0.1):
|
|
"""Lissage qui respecte les contours (filtre guide par l'image elle-meme).
|
|
|
|
Dans une fenetre de `radius` echantillons, les variations plus faibles que
|
|
`contrast` (texture de la peau, grain) sont aplanies, tandis que les
|
|
transitions plus marquees (bord d'un visage, yeux, bouche) restent nettes,
|
|
la ou un flou gaussien les etalerait.
|
|
"""
|
|
field = np.asarray(field, dtype=float)
|
|
radius = int(radius)
|
|
if radius < 1:
|
|
return field.copy()
|
|
mean = box_blur(field, radius)
|
|
variance = np.maximum(box_blur(field * field, radius) - mean * mean, 0.0)
|
|
# keep = 1 sur un contour (on garde l'image), 0 en zone calme (on garde
|
|
# la moyenne locale).
|
|
keep = variance / (variance + contrast ** 2)
|
|
return box_blur(keep, radius) * field + box_blur(mean * (1.0 - keep), radius)
|
|
|
|
|
|
def smooth(field, blur, preserve_edges=False):
|
|
"""Lisse un champ avant sa decoupe en niveaux, puis l'etire sur 0..1.
|
|
|
|
`blur` est la taille du lissage en echantillons. Sans `preserve_edges`,
|
|
c'est l'ecart-type d'un flou gaussien. Avec, c'est le rayon d'un lissage
|
|
qui respecte les contours, suivi d'un leger flou gaussien (un cinquieme)
|
|
qui arrondit le trace des lignes de niveau.
|
|
"""
|
|
field = normalize(field)
|
|
if blur > 0 and preserve_edges:
|
|
field = edge_preserving_blur(field, max(1, int(round(blur))))
|
|
field = gaussian_blur(field, blur / 5.0)
|
|
elif blur > 0:
|
|
field = gaussian_blur(field, blur)
|
|
return normalize(field)
|
|
|
|
|
|
def normalize(field):
|
|
"""Etire le champ sur toute la plage 0..1 (champ uniforme : tout a 0)."""
|
|
field = np.asarray(field, dtype=float)
|
|
low, high = float(field.min()), float(field.max())
|
|
if high - low < 1e-9:
|
|
return np.zeros_like(field)
|
|
return (field - low) / (high - low)
|
|
|
|
|
|
def spread_levels(field, amount=1.0, bins=1024):
|
|
"""Repartit les valeurs du champ pour que les niveaux aient la meme aire.
|
|
|
|
Le champ est remappe par sa fonction de repartition (egalisation
|
|
d'histogramme) : des seuils reguliers decoupent alors des surfaces egales,
|
|
meme si presque toutes les valeurs sont tassees sur une plage etroite
|
|
(carte de profondeur : fond lointain etendu, sujet resserre). `amount`
|
|
dose l'effet, de 0 (champ etire sur 0..1, sans plus) a 1 (aires egales).
|
|
"""
|
|
field = normalize(field)
|
|
amount = min(1.0, max(0.0, float(amount)))
|
|
if amount <= 0:
|
|
return field
|
|
counts, edges = np.histogram(field, bins=bins, range=(0.0, 1.0))
|
|
share = np.concatenate([[0.0], np.cumsum(counts)]) / float(field.size)
|
|
return (1.0 - amount) * field + amount * np.interp(field, edges, share)
|
|
|
|
|
|
def guided_filter(guide, source, radius, eps=1e-3):
|
|
"""Lisse `source` en lui donnant les contours de `guide` (filtre guide).
|
|
|
|
Dans chaque fenetre de `radius` echantillons, `source` est approche par
|
|
une fonction affine de `guide` : un bord flou de `source` (carte de
|
|
profondeur calculee en basse resolution) se recale sur le bord net
|
|
correspondant de `guide` (la photo). `eps` est la variance de `guide` en
|
|
dessous de laquelle une zone est tenue pour calme, donc simplement lissee.
|
|
"""
|
|
guide = np.asarray(guide, dtype=float)
|
|
source = np.asarray(source, dtype=float)
|
|
radius = int(radius)
|
|
if radius < 1:
|
|
return source.copy()
|
|
mean_guide, mean_source = box_blur(guide, radius), box_blur(source, radius)
|
|
covariance = box_blur(guide * source, radius) - mean_guide * mean_source
|
|
variance = np.maximum(box_blur(guide * guide, radius) - mean_guide ** 2, 0.0)
|
|
slope = covariance / (variance + eps)
|
|
offset = mean_source - slope * mean_guide
|
|
return box_blur(slope, radius) * guide + box_blur(offset, radius)
|
|
|
|
|
|
def add_detail(depth, gray, amount, sigma):
|
|
"""Grave dans la profondeur les contrastes fins des gris.
|
|
|
|
Une carte de profondeur donne les volumes mais gomme les yeux, la bouche,
|
|
les sourcils. On lui ajoute les gris prives de leurs grandes variations
|
|
(ecart a leur moyenne locale, sur `sigma` echantillons) : un trait sombre
|
|
se creuse, un reflet ressort, alors qu'un fond uni, clair ou sombre,
|
|
n'apporte rien et reste a sa profondeur. `amount` dose l'effet (0 : la
|
|
profondeur seule). Le resultat reste sur 0..1.
|
|
"""
|
|
depth = normalize(depth)
|
|
if amount <= 0:
|
|
return depth
|
|
gray = normalize(gray)
|
|
return np.clip(depth + amount * (gray - gaussian_blur(gray, sigma)), 0.0, 1.0)
|
|
|
|
|
|
def blend_fields(gray, depth, mix):
|
|
"""Melange les gris et la profondeur, chacun etire sur 0..1 : `mix` = 0
|
|
garde les gris seuls, 1 la profondeur seule."""
|
|
mix = min(1.0, max(0.0, float(mix)))
|
|
return (1.0 - mix) * normalize(gray) + mix * normalize(depth)
|
|
|
|
|
|
def thresholds(levels):
|
|
"""Seuils reguliers separant `levels` niveaux de gris sur 0..1."""
|
|
return [k / float(levels) for k in range(1, levels)]
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Contours
|
|
# --------------------------------------------------------------------------
|
|
|
|
def contour_rings(field, threshold, above=True):
|
|
"""Contour ferme de la region `field >= threshold` (ou `<=` si `above`
|
|
est faux), par « marching squares » avec interpolation lineaire.
|
|
|
|
Le champ est borde d'une valeur hors region : la ou la region touche le
|
|
bord de l'image, le contour longe ce bord, il est donc toujours ferme.
|
|
"""
|
|
field = np.asarray(field, dtype=float)
|
|
height, width = field.shape
|
|
if height < 2 or width < 2:
|
|
return []
|
|
signed = (field - threshold) if above else (threshold - field)
|
|
g = np.full((height + 2, width + 2), _OUTSIDE)
|
|
g[1:-1, 1:-1] = signed
|
|
rows, cols = g.shape
|
|
|
|
inside = g >= 0
|
|
tl, tr = inside[:-1, :-1], inside[:-1, 1:]
|
|
bl, br = inside[1:, :-1], inside[1:, 1:]
|
|
case = 8 * tl + 4 * tr + 2 * br + 1 * bl
|
|
center = (g[:-1, :-1] + g[:-1, 1:] + g[1:, :-1] + g[1:, 1:]) >= 0
|
|
|
|
# Identifiant entier de chaque arete de la grille : les deux cellules qui
|
|
# partagent une arete designent ainsi exactement le meme point de contour.
|
|
ii, jj = np.indices(case.shape)
|
|
count = rows * cols
|
|
edge = {
|
|
"T": ii * cols + jj, # arete horizontale (i, j)-(i, j+1)
|
|
"B": (ii + 1) * cols + jj,
|
|
"L": count + ii * cols + jj, # arete verticale (i, j)-(i+1, j)
|
|
"R": count + ii * cols + jj + 1,
|
|
}
|
|
|
|
starts, ends = [], []
|
|
|
|
def add(mask, segments):
|
|
for first, second in segments:
|
|
starts.append(edge[first][mask])
|
|
ends.append(edge[second][mask])
|
|
|
|
for number, segments in _CASES.items():
|
|
add(case == number, segments)
|
|
for number, (if_inside, if_outside) in _SADDLES.items():
|
|
add((case == number) & center, if_inside)
|
|
add((case == number) & ~center, if_outside)
|
|
|
|
starts = np.concatenate(starts)
|
|
ends = np.concatenate(ends)
|
|
if starts.size == 0:
|
|
return []
|
|
|
|
# Chaque arete traversee appartient a exactement deux segments : on en
|
|
# deduit, pour chaque point, ses deux voisins le long du contour.
|
|
ids, inverse = np.unique(np.concatenate([starts, ends]), return_inverse=True)
|
|
first, second = inverse[:starts.size], inverse[starts.size:]
|
|
node = np.concatenate([first, second])
|
|
other = np.concatenate([second, first])
|
|
order = np.argsort(node, kind="stable")
|
|
neighbours = other[order].reshape(-1, 2).tolist()
|
|
|
|
points = _edge_points(g, ids, cols, count)
|
|
# Retour au repere de l'image (sans la bordure), bord compris.
|
|
points -= 1.0
|
|
np.clip(points[:, 0], 0, width - 1, out=points[:, 0])
|
|
np.clip(points[:, 1], 0, height - 1, out=points[:, 1])
|
|
|
|
rings = []
|
|
visited = [False] * len(neighbours)
|
|
for start in range(len(neighbours)):
|
|
if visited[start]:
|
|
continue
|
|
chain, previous, current = [], -1, start
|
|
while not visited[current]:
|
|
visited[current] = True
|
|
chain.append(current)
|
|
n0, n1 = neighbours[current]
|
|
previous, current = current, (n1 if n0 == previous else n0)
|
|
ring = _drop_duplicates(points[chain])
|
|
if len(ring) >= 3:
|
|
rings.append(ring)
|
|
return rings
|
|
|
|
|
|
def _edge_points(g, ids, cols, count):
|
|
"""Point de passage du contour sur chaque arete (repere du champ borde)."""
|
|
vertical = ids >= count
|
|
flat = np.where(vertical, ids - count, ids)
|
|
i, j = flat // cols, flat % cols
|
|
a = g[i, j]
|
|
b = np.where(vertical, g[np.minimum(i + 1, g.shape[0] - 1), j],
|
|
g[i, np.minimum(j + 1, cols - 1)])
|
|
t = a / (a - b)
|
|
x = np.where(vertical, j, j + t)
|
|
y = np.where(vertical, i + t, i)
|
|
return np.column_stack([x, y]).astype(float)
|
|
|
|
|
|
def _drop_duplicates(ring, eps=1e-7):
|
|
"""Retire les points consecutifs confondus (coins de l'image, valeurs
|
|
tombant pile sur le seuil)."""
|
|
if len(ring) < 2:
|
|
return ring
|
|
step = np.abs(ring - np.roll(ring, 1, axis=0)).max(axis=1)
|
|
return ring[step > eps]
|
|
|
|
|
|
def ring_area(ring):
|
|
"""Aire d'un anneau (formule du lacet, valeur absolue)."""
|
|
ring = np.asarray(ring, dtype=float)
|
|
if len(ring) < 3:
|
|
return 0.0
|
|
x, y = ring[:, 0], ring[:, 1]
|
|
return 0.5 * abs(float(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1))))
|
|
|
|
|
|
def _keep_mask(points, tolerance, anchors):
|
|
"""Douglas-Peucker : masque des points conserves entre les `anchors`
|
|
(indices croissants, toujours conserves)."""
|
|
keep = np.zeros(len(points), dtype=bool)
|
|
keep[list(anchors)] = True
|
|
stack = list(zip(anchors[:-1], anchors[1:]))
|
|
while stack:
|
|
first, last = stack.pop()
|
|
if last - first < 2:
|
|
continue
|
|
p, q = points[first], points[last]
|
|
inner = points[first + 1:last]
|
|
dx, dy = q - p
|
|
length = float(np.hypot(dx, dy))
|
|
if length < 1e-12:
|
|
distance = np.hypot(inner[:, 0] - p[0], inner[:, 1] - p[1])
|
|
else:
|
|
distance = np.abs(dx * (inner[:, 1] - p[1]) - dy * (inner[:, 0] - p[0])) / length
|
|
worst = int(np.argmax(distance))
|
|
if distance[worst] > tolerance:
|
|
middle = first + 1 + worst
|
|
keep[middle] = True
|
|
stack.append((first, middle))
|
|
stack.append((middle, last))
|
|
return keep
|
|
|
|
|
|
def simplify_ring(ring, tolerance):
|
|
"""Simplification de Douglas-Peucker d'un anneau ferme.
|
|
|
|
Aucun point conserve ne s'ecarte de plus de `tolerance` du trace d'origine.
|
|
"""
|
|
ring = np.asarray(ring, dtype=float)
|
|
count = len(ring)
|
|
if tolerance <= 0 or count < 5:
|
|
return ring
|
|
# Deux ancres : le premier point et le plus eloigne de lui.
|
|
far = int(np.argmax(((ring - ring[0]) ** 2).sum(axis=1)))
|
|
if far == 0:
|
|
return ring
|
|
closed = np.vstack([ring, ring[:1]])
|
|
return closed[:-1][_keep_mask(closed, tolerance, [0, far, count])[:-1]]
|
|
|
|
|
|
def simplify_chain(chain, tolerance):
|
|
"""Simplification de Douglas-Peucker d'une ligne ouverte (extremites fixes)."""
|
|
chain = np.asarray(chain, dtype=float)
|
|
if tolerance <= 0 or len(chain) < 3:
|
|
return chain
|
|
return chain[_keep_mask(chain, tolerance, [0, len(chain) - 1])]
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Lignes de niveau et bord de l'image
|
|
#
|
|
# Le bord de l'image est repere par une abscisse curviligne `s`, qui part du
|
|
# coin haut-gauche et tourne dans le sens horaire : haut, droite, bas, gauche.
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _sides(points, right, bottom):
|
|
"""Pour chaque point, les cotes de l'image qu'il touche : colonnes haut,
|
|
droite, bas, gauche."""
|
|
eps = 1e-6 * max(right, bottom, 1e-12)
|
|
x, y = points[:, 0], points[:, 1]
|
|
return np.column_stack([np.abs(y) < eps, np.abs(x - right) < eps,
|
|
np.abs(y - bottom) < eps, np.abs(x) < eps])
|
|
|
|
|
|
def _border_position(point, right, bottom):
|
|
"""Abscisse curviligne d'un point pose sur le bord de l'image."""
|
|
top, on_right, on_bottom, _left = _sides(np.asarray([point]), right, bottom)[0]
|
|
x, y = float(point[0]), float(point[1])
|
|
if top:
|
|
return x
|
|
if on_right:
|
|
return right + y
|
|
if on_bottom:
|
|
return right + bottom + (right - x)
|
|
return (2 * right + bottom + (bottom - y)) % (2 * (right + bottom))
|
|
|
|
|
|
def _border_point(position, right, bottom):
|
|
"""Point du bord de l'image d'abscisse curviligne `position`."""
|
|
position %= 2 * (right + bottom)
|
|
if position <= right:
|
|
return position, 0.0
|
|
if position <= right + bottom:
|
|
return right, position - right
|
|
if position <= 2 * right + bottom:
|
|
return right - (position - right - bottom), bottom
|
|
return 0.0, bottom - (position - 2 * right - bottom)
|
|
|
|
|
|
def _border_corners(start, end, right, bottom):
|
|
"""Coins de l'image rencontres en longeant le bord de `start` a `end`
|
|
dans le sens des abscisses croissantes."""
|
|
perimeter = 2 * (right + bottom)
|
|
span = (end - start) % perimeter
|
|
corners = []
|
|
for position, corner in ((0.0, (0.0, 0.0)), (right, (right, 0.0)),
|
|
(right + bottom, (right, bottom)),
|
|
(2 * right + bottom, (0.0, bottom))):
|
|
distance = (position - start) % perimeter
|
|
if 0 < distance < span:
|
|
corners.append((distance, corner))
|
|
return [corner for _distance, corner in sorted(corners)]
|
|
|
|
|
|
def _border_value(field, position):
|
|
"""Valeur du champ au point du bord d'abscisse `position` (interpolee)."""
|
|
rows, cols = field.shape
|
|
x, y = _border_point(position, cols - 1.0, rows - 1.0)
|
|
j, i = min(int(x), cols - 2), min(int(y), rows - 2)
|
|
u, v = x - j, y - i
|
|
return ((1 - u) * (1 - v) * field[i, j] + u * (1 - v) * field[i, j + 1]
|
|
+ (1 - u) * v * field[i + 1, j] + u * v * field[i + 1, j + 1])
|
|
|
|
|
|
def contour_lines(field, threshold, tolerance=0.0, min_area=0.0):
|
|
"""Ligne de niveau `field = threshold`, simplifiee une fois pour toutes.
|
|
|
|
Renvoie `(closed, opened)` : les boucles fermees a l'interieur de l'image,
|
|
et les lignes ouvertes, dont les deux extremites sont sur le bord. Les
|
|
deux regions que la ligne separe la reutilisent telle quelle : leurs
|
|
frontieres communes coincident donc exactement. Les boucles et les lignes
|
|
qui delimitent une aire inferieure a `min_area` (en echantillons carres)
|
|
sont ecartees.
|
|
"""
|
|
field = np.asarray(field, dtype=float)
|
|
right, bottom = field.shape[1] - 1.0, field.shape[0] - 1.0
|
|
closed, opened = [], []
|
|
for ring in contour_rings(field, threshold):
|
|
sides = _sides(ring, right, bottom)
|
|
# Segment i -> i+1 pose sur le bord : ses deux bouts sur un meme cote.
|
|
border = (sides & np.roll(sides, -1, axis=0)).any(axis=1)
|
|
if border.all():
|
|
continue
|
|
if not border.any():
|
|
if ring_area(ring) >= min_area:
|
|
ring = simplify_ring(ring, tolerance)
|
|
if len(ring) >= 3:
|
|
closed.append(ring)
|
|
continue
|
|
count = len(ring)
|
|
start = next(i for i in range(count) if border[i - 1] and not border[i])
|
|
chain = [ring[start]]
|
|
for step in range(count):
|
|
i = (start + step) % count
|
|
if not border[i]:
|
|
chain.append(ring[(i + 1) % count])
|
|
continue
|
|
if len(chain) > 1:
|
|
chain = np.array(chain)
|
|
if _cut_area(chain, right, bottom) >= min_area:
|
|
chain = simplify_chain(chain, tolerance)
|
|
# Une ligne reduite a deux points d'un meme cote de
|
|
# l'image ne detache plus rien : elle longe le bord.
|
|
if len(chain) > 2 or not _sides(chain, right, bottom).all(axis=0).any():
|
|
opened.append(chain)
|
|
chain = [ring[(i + 1) % count]]
|
|
return closed, opened
|
|
|
|
|
|
def _cut_area(chain, right, bottom):
|
|
"""Aire de la plus petite des deux parts que la ligne detache de l'image."""
|
|
corners = _border_corners(_border_position(chain[-1], right, bottom),
|
|
_border_position(chain[0], right, bottom),
|
|
right, bottom)
|
|
area = ring_area(np.vstack([chain] + [np.array([c]) for c in corners]))
|
|
return min(area, right * bottom - area)
|
|
|
|
|
|
def region_rings(field, lines, inside):
|
|
"""Contour ferme d'une region bornee par des lignes de niveau.
|
|
|
|
`lines` est la liste des `(closed, opened)` de `contour_lines` qui bordent
|
|
la region ; `inside(valeur)` dit si une valeur du champ est dans la region.
|
|
Les lignes ouvertes sont refermees en longeant le bord de l'image, du
|
|
cote ou il appartient a la region.
|
|
"""
|
|
field = np.asarray(field, dtype=float)
|
|
right, bottom = field.shape[1] - 1.0, field.shape[0] - 1.0
|
|
perimeter = 2 * (right + bottom)
|
|
|
|
def on_border(position):
|
|
return inside(_border_value(field, position))
|
|
|
|
rings = [ring for closed, _opened in lines for ring in closed]
|
|
chains = [chain for _closed, opened in lines for chain in opened]
|
|
if not chains:
|
|
# Aucune ligne n'atteint le bord : il est tout entier dans la region,
|
|
# ou tout entier dehors.
|
|
probes = [on_border(perimeter * (k + 0.5) / 32.0) for k in range(32)]
|
|
if sum(probes) > 16:
|
|
rings.append(image_ring(int(right) + 1, int(bottom) + 1))
|
|
return rings
|
|
|
|
# Extremites des lignes, dans l'ordre ou on les rencontre sur le bord :
|
|
# entre deux extremites voisines, le bord est alternativement dans la
|
|
# region et hors d'elle.
|
|
ends = sorted((_border_position(chain[-end], right, bottom), index, end)
|
|
for index, chain in enumerate(chains) for end in (0, 1))
|
|
count = len(ends)
|
|
score = 0.0
|
|
for i in range(count):
|
|
span = (ends[(i + 1) % count][0] - ends[i][0]) % perimeter
|
|
# Plusieurs sondes par intervalle : une seule pourrait tomber sur une
|
|
# petite forme ecartee par `min_area` et fausser le vote.
|
|
votes = sum(1 if on_border((ends[i][0] + span * (n + 0.5) / 9.0) % perimeter)
|
|
else -1 for n in range(9))
|
|
score += span * votes * (1 if i % 2 == 0 else -1)
|
|
first = 0 if score >= 0 else 1
|
|
|
|
# link[i] = (extremite reliee a i par le bord, sens de parcours du bord)
|
|
link = {}
|
|
for i in range(first, first + count, 2):
|
|
start, end = i % count, (i + 1) % count
|
|
link[start] = (end, 1)
|
|
link[end] = (start, -1)
|
|
where = {(index, end): i for i, (_s, index, end) in enumerate(ends)}
|
|
|
|
used = [False] * len(chains)
|
|
for origin in range(len(chains)):
|
|
if used[origin]:
|
|
continue
|
|
points, index, entry = [], origin, 0
|
|
while not used[index]:
|
|
used[index] = True
|
|
chain = chains[index]
|
|
points.extend(chain if entry == 0 else chain[::-1])
|
|
leave = where[(index, 1 - entry)]
|
|
reach, direction = link[leave]
|
|
if direction > 0:
|
|
corners = _border_corners(ends[leave][0], ends[reach][0], right, bottom)
|
|
else:
|
|
corners = _border_corners(ends[reach][0], ends[leave][0], right, bottom)[::-1]
|
|
points.extend(corners)
|
|
_s, index, entry = ends[reach]
|
|
ring = _drop_duplicates(np.array(points, dtype=float))
|
|
if len(ring) >= 3:
|
|
rings.append(ring)
|
|
return rings
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Couches
|
|
# --------------------------------------------------------------------------
|
|
|
|
def iso_boards(field, levels=16, blur=0.0, min_area=0.0, tolerance=0.0,
|
|
shapes="light", preserve_edges=False, equalize=0.0):
|
|
"""Decoupe un champ de gris en une planche par niveau.
|
|
|
|
Le champ est lisse (`blur` en echantillons, `preserve_edges` : voir
|
|
`smooth`), etire sur 0..1 puis quantifie en `levels` niveaux a seuils
|
|
reguliers : le niveau k couvre les valeurs de k / levels a
|
|
(k + 1) / levels. Avec `equalize` (0 a 1, voir `spread_levels`), les
|
|
valeurs sont d'abord reparties pour que les niveaux tendent vers des
|
|
aires egales. Renvoie les planches du fond vers l'avant de la pile :
|
|
`(niveau, gris, rings, mark)` avec `niveau` de 0 (le plus sombre) a
|
|
levels - 1 et `gris` entre 0 et 1.
|
|
|
|
`shapes` choisit la forme de chaque planche :
|
|
- "light" : le niveau et tous les plus clairs (planches a empiler, la plus
|
|
sombre au fond, couvrant toute l'image) ;
|
|
- "dark" : le niveau et tous les plus sombres (la plus claire au fond) ;
|
|
- "band" : l'aplat du niveau seul, borde sur tous ses cotes ; les planches
|
|
pavent l'image sans se recouvrir.
|
|
|
|
`mark` est le repere d'assemblage : la ligne de niveau `(closed, opened)`
|
|
qui borde la planche suivante de la pile, a reporter sur celle-ci pour
|
|
savoir ou la poser. Elle a exactement le trace de la decoupe de la
|
|
planche suivante, sans les troncons qui longent le bord de l'image. Il
|
|
vaut `None` pour la derniere planche et pour les planches "band", qui ne
|
|
s'empilent pas.
|
|
|
|
Dans tous les cas les contours sont les lignes de niveau des seuils, donc
|
|
les frontieres des aplats de l'image quantifiee. Les formes d'aire
|
|
inferieure a `min_area` (fraction de la surface de l'image) sont
|
|
ecartees, les autres simplifiees a `tolerance` echantillons pres. Il y a
|
|
toujours `levels` planches : une planche sans forme est renvoyee vide.
|
|
"""
|
|
field = np.asarray(field, dtype=float)
|
|
height, width = field.shape
|
|
levels = max(2, int(levels))
|
|
field = smooth(field, blur, preserve_edges)
|
|
if equalize > 0:
|
|
field = spread_levels(field, equalize)
|
|
smallest = min_area * (width - 1) * (height - 1)
|
|
|
|
cuts = thresholds(levels)
|
|
# lines[k] : ligne de niveau du seuil k / levels, entre les niveaux k - 1 et k.
|
|
lines = [None] + [contour_lines(field, cut, tolerance, smallest) for cut in cuts]
|
|
lines.append(None)
|
|
low = [-np.inf] + cuts
|
|
high = cuts + [np.inf]
|
|
|
|
def band(k):
|
|
borders = [lines[n] for n in (k, k + 1) if lines[n] is not None]
|
|
return region_rings(field, borders, lambda v: low[k] <= v < high[k]), None
|
|
|
|
def lighter(k):
|
|
# Bordee par lines[k] ; la planche suivante (k + 1) par lines[k + 1].
|
|
borders = [lines[k]] if lines[k] is not None else []
|
|
return region_rings(field, borders, lambda v: v >= low[k]), lines[k + 1]
|
|
|
|
def darker(k):
|
|
# Bordee par lines[k + 1] ; la planche suivante (k - 1) par lines[k].
|
|
borders = [lines[k + 1]] if lines[k + 1] is not None else []
|
|
return region_rings(field, borders, lambda v: v < high[k]), lines[k]
|
|
|
|
if shapes == "dark":
|
|
order, board = range(levels - 1, -1, -1), darker
|
|
else:
|
|
order, board = range(levels), (band if shapes == "band" else lighter)
|
|
return [(k, k / float(levels - 1)) + board(k) for k in order]
|
|
|
|
|
|
def iso_layers(field, levels=16, blur=0.0, min_area=0.0, tolerance=0.0,
|
|
shapes="band", preserve_edges=False, equalize=0.0):
|
|
"""Comme `iso_boards`, sans les reperes d'assemblage : liste de
|
|
`(niveau, gris, rings)`."""
|
|
return [board[:3] for board in iso_boards(field, levels, blur, min_area,
|
|
tolerance, shapes, preserve_edges,
|
|
equalize)]
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Numeros des planches
|
|
#
|
|
# Chaque piece d'une planche porte son numero, encadre, et chaque zone ou se
|
|
# pose la planche suivante porte le numero de celle-ci. Les numeros sont
|
|
# traces en segments droits, sans police : ils se gravent tels quels.
|
|
# --------------------------------------------------------------------------
|
|
|
|
# Segments d'un chiffre dans une case de 0.5 x 1 : a haut, b droite haut,
|
|
# c droite bas, d bas, e gauche bas, f gauche haut, g milieu.
|
|
_SEGMENTS = {
|
|
"a": ((0.0, 0.0), (0.5, 0.0)), "b": ((0.5, 0.0), (0.5, 0.5)),
|
|
"c": ((0.5, 0.5), (0.5, 1.0)), "d": ((0.0, 1.0), (0.5, 1.0)),
|
|
"e": ((0.0, 0.5), (0.0, 1.0)), "f": ((0.0, 0.0), (0.0, 0.5)),
|
|
"g": ((0.0, 0.5), (0.5, 0.5)),
|
|
}
|
|
_DIGITS = {"0": "abcdef", "1": "bc", "2": "abged", "3": "abgcd", "4": "fgbc",
|
|
"5": "afgcd", "6": "afgedc", "7": "abc", "8": "abcdefg", "9": "abcdfg"}
|
|
_DIGIT_STEP = 0.8 # pas d'un chiffre au suivant, en hauteurs de chiffre
|
|
_FRAME_MARGIN = 0.35 # marge du cadre autour du numero, idem
|
|
|
|
|
|
def number_size(number, height, framed=False):
|
|
"""Largeur et hauteur du numero trace par `number_lines`."""
|
|
count = len(str(int(number)))
|
|
width = height * (0.5 + _DIGIT_STEP * (count - 1))
|
|
margin = 2 * _FRAME_MARGIN * height if framed else 0.0
|
|
return width + margin, height + margin
|
|
|
|
|
|
def number_lines(number, x, y, height, framed=False):
|
|
"""Trace du numero centre en (x, y), chiffres hauts de `height`.
|
|
|
|
Renvoie une ligne `(closed, opened)` comme `contour_lines` : les segments
|
|
des chiffres en lignes ouvertes, et le cadre (`framed`) en boucle fermee.
|
|
"""
|
|
text = str(int(number))
|
|
width, _height = number_size(number, height)
|
|
left, top = x - width / 2.0, y - height / 2.0
|
|
opened = []
|
|
for k, digit in enumerate(text):
|
|
origin = np.array([left + k * _DIGIT_STEP * height, top])
|
|
for name in _DIGITS[digit]:
|
|
opened.append(origin + height * np.array(_SEGMENTS[name]))
|
|
closed = []
|
|
if framed:
|
|
w, h = number_size(number, height, True)
|
|
closed.append(np.array([(x - w / 2, y - h / 2), (x + w / 2, y - h / 2),
|
|
(x + w / 2, y + h / 2), (x - w / 2, y + h / 2)]))
|
|
return closed, opened
|
|
|
|
|
|
def region_mask(rings, width, height, step):
|
|
"""Grille de booleens : la region bornee par `rings` (regle pair-impair),
|
|
echantillonnee tous les `step` echantillons. Renvoie `(mask, xs, ys)`,
|
|
les abscisses et ordonnees des colonnes et des lignes de la grille."""
|
|
xs = np.arange(step / 2.0, width - 1.0, step)
|
|
ys = np.arange(step / 2.0, height - 1.0, step)
|
|
mask = np.zeros((len(ys), len(xs)), dtype=bool)
|
|
rings = [np.asarray(ring, dtype=float) for ring in rings if len(ring) >= 3]
|
|
if not rings:
|
|
return mask, xs, ys
|
|
start = np.vstack(rings)
|
|
end = np.vstack([np.roll(ring, -1, axis=0) for ring in rings])
|
|
x0, y0, x1, y1 = start[:, 0], start[:, 1], end[:, 0], end[:, 1]
|
|
for i, y in enumerate(ys):
|
|
crossing = (y0 <= y) != (y1 <= y)
|
|
if not crossing.any():
|
|
continue
|
|
a, b = y0[crossing], y1[crossing]
|
|
at = x0[crossing] + (y - a) * (x1[crossing] - x0[crossing]) / (b - a)
|
|
mask[i] = np.searchsorted(np.sort(at), xs) % 2 == 1
|
|
return mask, xs, ys
|
|
|
|
|
|
def mask_depth(mask):
|
|
"""Pour chaque case du masque, sa distance au bord de la region, en
|
|
cases (1 sur le bord, 0 dehors) : nombre d'erosions qu'elle supporte."""
|
|
depth = np.zeros(mask.shape, dtype=int)
|
|
current = np.pad(np.asarray(mask, dtype=bool), 1)
|
|
while current.any():
|
|
depth += current[1:-1, 1:-1]
|
|
current[1:-1, 1:-1] = (current[1:-1, 1:-1] & current[:-2, 1:-1]
|
|
& current[2:, 1:-1] & current[1:-1, :-2]
|
|
& current[1:-1, 2:])
|
|
return depth
|
|
|
|
|
|
def mask_components(mask):
|
|
"""Parties d'un seul tenant du masque (voisinage par les cotes) :
|
|
tableau d'etiquettes, 0 hors du masque, puis 1, 2..., et leur nombre."""
|
|
mask = np.asarray(mask, dtype=bool)
|
|
if not mask.any():
|
|
return np.zeros(mask.shape, dtype=int), 0
|
|
# Chaque case part avec un numero a elle, puis le plus grand numero se
|
|
# propage le long des lignes et des colonnes, dans les deux sens, jusqu'a
|
|
# ce que plus rien ne bouge : toute une partie finit avec le meme numero.
|
|
big = mask.size + 1
|
|
labels = np.where(mask, np.arange(1, big, dtype=np.int64).reshape(mask.shape), 0)
|
|
while True:
|
|
before = labels
|
|
for axis in (1, 0):
|
|
for flip in (False, True):
|
|
view = np.flip(labels, axis) if flip else labels
|
|
inside = np.flip(mask, axis) if flip else mask
|
|
# Numero de troncon : le maximum cumule ne passe pas d'un
|
|
# troncon de cases au suivant.
|
|
run = np.cumsum(~inside, axis=axis) * big
|
|
view = np.where(inside, np.maximum.accumulate(view + run, axis=axis) - run, 0)
|
|
labels = np.flip(view, axis) if flip else view
|
|
if np.array_equal(labels, before):
|
|
break
|
|
_values, compact = np.unique(labels, return_inverse=True)
|
|
compact = compact.reshape(mask.shape)
|
|
if mask.all(): # pas de case a 0 : les etiquettes partent de 1
|
|
compact = compact + 1
|
|
return compact, int(compact.max())
|
|
|
|
|
|
def _number_height(number, radius, largest, framed):
|
|
"""Plus grande hauteur de chiffre, au plus `largest`, dont le numero
|
|
tient dans un disque de rayon `radius`."""
|
|
width, height = number_size(number, 1.0, framed)
|
|
return min(largest, 2.0 * radius / float(np.hypot(width, height)))
|
|
|
|
|
|
def board_labels(rings, following, number, width, height, largest, smallest,
|
|
step=None):
|
|
"""Emplacement des numeros a graver sur la planche `number`.
|
|
|
|
`rings` est le contour de la planche, `following` celui de la planche
|
|
suivante de la pile (ou `None`). Renvoie `(own, next)`, deux listes de
|
|
`(x, y, hauteur des chiffres)` en echantillons :
|
|
- `next` : le numero de la planche suivante, au creux de chaque zone ou
|
|
elle se pose ;
|
|
- `own` : le numero de la planche, un par piece d'un seul tenant, a
|
|
encadrer. Il se place de preference sous la planche suivante, a l'ecart
|
|
du numero de celle-ci, pour etre cache une fois la pile collee ; sinon
|
|
sur la partie qui reste visible.
|
|
Les chiffres mesurent au plus `largest` ; une piece ou une zone trop
|
|
etroite pour des chiffres de `smallest` ne recoit pas de numero.
|
|
"""
|
|
if step is None:
|
|
step = max(1.0, max(width, height) / 200.0)
|
|
region, xs, ys = region_mask(rings, width, height, step)
|
|
taken = np.zeros(region.shape, dtype=bool)
|
|
grid_y, grid_x = np.indices(region.shape)
|
|
|
|
def spots(mask, shown, framed):
|
|
"""Un numero par partie de `mask`, au plus profond de celle-ci."""
|
|
found = []
|
|
depth = mask_depth(mask)
|
|
labels, count = mask_components(mask)
|
|
for label in range(1, count + 1):
|
|
reach = np.where(labels == label, depth, 0)
|
|
i, j = np.unravel_index(int(np.argmax(reach)), reach.shape)
|
|
size = _number_height(shown, (reach[i, j] - 0.5) * step, largest, framed)
|
|
if size < smallest:
|
|
continue
|
|
found.append((float(xs[j]), float(ys[i]), size, label))
|
|
box = number_size(shown, size, framed)
|
|
radius = 0.6 * float(np.hypot(*box)) / step + 1.0
|
|
taken[(grid_x - j) ** 2 + (grid_y - i) ** 2 <= radius ** 2] = True
|
|
return found, labels
|
|
|
|
covered = np.zeros(region.shape, dtype=bool)
|
|
after = []
|
|
if following:
|
|
covered = region_mask(following, width, height, step)[0]
|
|
after = [spot[:3] for spot in spots(covered, number + 1, False)[0]]
|
|
|
|
pieces, _count = mask_components(region)
|
|
own, done = [], set()
|
|
# D'abord sous la planche suivante (numero cache), puis a decouvert.
|
|
for mask in (region & covered & ~taken, region & ~covered & ~taken):
|
|
found, _labels = spots(mask, number, True)
|
|
for x, y, size, _label in sorted(found, key=lambda spot: -spot[2]):
|
|
j = int(np.argmin(np.abs(xs - x)))
|
|
i = int(np.argmin(np.abs(ys - y)))
|
|
piece = pieces[i, j]
|
|
if piece and piece not in done:
|
|
done.add(piece)
|
|
own.append((x, y, size))
|
|
return own, after
|
|
|
|
|
|
def image_ring(width, height):
|
|
"""Anneau du rectangle de l'image, en echantillons."""
|
|
return np.array([(0.0, 0.0), (width - 1.0, 0.0),
|
|
(width - 1.0, height - 1.0), (0.0, height - 1.0)])
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Ecriture SVG
|
|
# --------------------------------------------------------------------------
|
|
|
|
def scale_rings(rings, scale_x, scale_y, offset_x=0.0, offset_y=0.0):
|
|
"""Passe des echantillons au repere de destination."""
|
|
factor = np.array([scale_x, scale_y], dtype=float)
|
|
offset = np.array([offset_x, offset_y], dtype=float)
|
|
return [np.asarray(ring, dtype=float) * factor + offset for ring in rings]
|
|
|
|
|
|
def scale_lines(lines, scale_x, scale_y, offset_x=0.0, offset_y=0.0):
|
|
"""Comme `scale_rings`, pour une ligne de niveau `(closed, opened)`."""
|
|
return tuple(scale_rings(part, scale_x, scale_y, offset_x, offset_y)
|
|
for part in lines)
|
|
|
|
|
|
def rings_to_d(rings, smooth=True, box=None, precision=3, matrix=None):
|
|
"""Donnees `d` d'un chemin SVG : un sous-chemin ferme par anneau.
|
|
|
|
Avec `smooth`, les points sont relies par une spline de Catmull-Rom
|
|
convertie en courbes de Bezier. `box` = (x0, y0, x1, y1) est le rectangle
|
|
de l'image : les troncons qui le longent restent des droites et les
|
|
courbes n'en sortent pas, pour que le bord des couches reste net.
|
|
|
|
`matrix` = ((a, c, e), (b, d, f)) est une transformation affine appliquee
|
|
aux points ecrits (une courbe de Bezier la subit sans se deformer) ; `box`
|
|
reste exprime dans le repere des anneaux.
|
|
"""
|
|
fmt = _formatter(precision, matrix)
|
|
parts = []
|
|
for ring in rings:
|
|
parts.extend(_subpath(ring, True, smooth, box, fmt))
|
|
return " ".join(parts)
|
|
|
|
|
|
def lines_to_d(lines, smooth=True, box=None, precision=3, matrix=None):
|
|
"""Donnees `d` d'une ligne de niveau `(closed, opened)` : un sous-chemin
|
|
ferme par boucle, un sous-chemin ouvert par ligne aboutissant au bord.
|
|
|
|
Memes options que `rings_to_d`, et meme trace : une ligne ecrite ici se
|
|
superpose exactement au contour de la region qu'elle borde.
|
|
"""
|
|
closed, opened = lines
|
|
fmt = _formatter(precision, matrix)
|
|
parts = []
|
|
for ring in closed:
|
|
parts.extend(_subpath(ring, True, smooth, box, fmt))
|
|
for chain in opened:
|
|
parts.extend(_subpath(chain, False, smooth, box, fmt))
|
|
return " ".join(parts)
|
|
|
|
|
|
def _formatter(precision, matrix):
|
|
"""Ecriture d'un point "x,y", apres transformation affine eventuelle."""
|
|
number = "{:.%df}" % precision
|
|
|
|
def fmt(point):
|
|
x, y = point[0], point[1]
|
|
if matrix is not None:
|
|
(a, c, e), (b, d, f) = matrix
|
|
x, y = a * x + c * y + e, b * x + d * y + f
|
|
return number.format(x) + "," + number.format(y)
|
|
|
|
return fmt
|
|
|
|
|
|
def _subpath(points, closed, smooth, box, fmt):
|
|
"""Commandes SVG d'un anneau (`closed`) ou d'une ligne ouverte."""
|
|
points = np.asarray(points, dtype=float)
|
|
count = len(points)
|
|
if count < (3 if closed else 2):
|
|
return []
|
|
parts = ["M " + fmt(points[0])]
|
|
if not smooth:
|
|
parts.extend("L " + fmt(point) for point in points[1:])
|
|
return parts + ["Z"] if closed else parts
|
|
|
|
on_box, along = _on_box(points, box)
|
|
if not closed:
|
|
# Les extremites d'une ligne ouverte sont sur le bord de l'image :
|
|
# meme traitement que dans l'anneau qui la contient.
|
|
on_box = on_box.copy()
|
|
on_box[[0, -1]] = True
|
|
before = np.roll(points, 1, axis=0)
|
|
after = np.roll(points, -1, axis=0)
|
|
# Sur le bord, la tangente ne doit pas etre tiree par le troncon droit
|
|
# voisin : on la replie sur le point lui-meme.
|
|
tangent = np.where(on_box[:, None], 0.0, (after - before) / 6.0)
|
|
for k in range(count if closed else count - 1):
|
|
nxt = (k + 1) % count
|
|
if along[k]:
|
|
# Le dernier troncon droit est trace par la fermeture « Z ».
|
|
if nxt:
|
|
parts.append("L " + fmt(points[nxt]))
|
|
continue
|
|
chord = (points[nxt] - points[k]) / 6.0
|
|
c1 = points[k] + (chord if on_box[k] else tangent[k])
|
|
c2 = points[nxt] - (chord if on_box[nxt] else tangent[nxt])
|
|
if box is not None:
|
|
c1 = np.clip(c1, box[:2], box[2:])
|
|
c2 = np.clip(c2, box[:2], box[2:])
|
|
parts.append("C {} {} {}".format(fmt(c1), fmt(c2), fmt(points[nxt])))
|
|
return parts + ["Z"] if closed else parts
|
|
|
|
|
|
def _on_box(ring, box):
|
|
"""Pour chaque point de l'anneau : est-il pose sur le rectangle `box`, et
|
|
le segment qui le relie au point suivant longe-t-il un cote de `box` ?"""
|
|
if box is None:
|
|
nowhere = np.zeros(len(ring), dtype=bool)
|
|
return nowhere, nowhere
|
|
x0, y0, x1, y1 = box
|
|
sides = _sides(ring - np.array([x0, y0]), x1 - x0, y1 - y0)
|
|
return sides.any(axis=1), (sides & np.roll(sides, -1, axis=0)).any(axis=1)
|