791 lines
34 KiB
Python
791 lines
34 KiB
Python
# coding=utf-8
|
|
"""Tests de l'extension « Gray Iso-Layers » (pytest)."""
|
|
|
|
import base64
|
|
import io
|
|
import math
|
|
import os
|
|
import re
|
|
import xml.etree.ElementTree as ET
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from gray_iso_layers_core import (
|
|
box_blur, contour_lines, contour_rings, edge_preserving_blur, gaussian_blur,
|
|
gray_to_hex, image_ring, iso_boards, iso_layers, lines_to_d, normalize,
|
|
parse_color, ring_area, rings_to_d, scale_lines, scale_rings, simplify_chain,
|
|
simplify_ring, smooth, thresholds)
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
|
def cone(size=101, radius=40.0):
|
|
"""Champ en cone : 1 au centre, 0 a `radius` echantillons et au-dela."""
|
|
y, x = np.mgrid[0:size, 0:size]
|
|
distance = np.hypot(x - (size - 1) / 2.0, y - (size - 1) / 2.0)
|
|
return np.clip(1.0 - distance / radius, 0.0, 1.0)
|
|
|
|
|
|
def ramp(width=60, height=40):
|
|
"""Rampe horizontale de 0 (gauche) a 1 (droite)."""
|
|
return np.tile(np.linspace(0.0, 1.0, width), (height, 1))
|
|
|
|
|
|
def evenodd_area(rings):
|
|
"""Aire d'un contour a trous imbriques simples : exterieur moins trous."""
|
|
areas = sorted((ring_area(ring) for ring in rings), reverse=True)
|
|
return sum(a if k % 2 == 0 else -a for k, a in enumerate(areas))
|
|
|
|
|
|
def contains(rings, x, y):
|
|
"""Le point est-il dans le contour, selon la regle pair-impair ?"""
|
|
crossings = 0
|
|
for ring in rings:
|
|
x0, y0 = ring[:, 0], ring[:, 1]
|
|
x1, y1 = np.roll(x0, -1), np.roll(y0, -1)
|
|
straddle = (y0 > y) != (y1 > y)
|
|
with np.errstate(divide="ignore", invalid="ignore"):
|
|
at = x0 + (y - y0) * (x1 - x0) / (y1 - y0)
|
|
crossings += int(np.count_nonzero(straddle & (at > x)))
|
|
return crossings % 2 == 1
|
|
|
|
|
|
def bilinear(field, x, y):
|
|
j, i = min(int(x), field.shape[1] - 2), min(int(y), field.shape[0] - 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 relief(seed=1, width=90, height=60, blur=4.0):
|
|
"""Relief aleatoire lisse, tel que l'extension le quantifie (deja floute
|
|
et etire : a passer a iso_layers avec blur=0)."""
|
|
rng = np.random.default_rng(seed)
|
|
return normalize(gaussian_blur(rng.random((height, width)), blur))
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Noyau (sans inkex)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def test_parse_color_inkscape_integer():
|
|
assert parse_color("3014898687") == ("#b3b3b3", 1.0)
|
|
assert parse_color(str(0xFF000080)) == ("#ff0000", round(0x80 / 255, 4))
|
|
|
|
|
|
def test_parse_color_hex_and_invalid():
|
|
assert parse_color("#123456") == ("#123456", 1.0)
|
|
assert parse_color("#abc") == ("#aabbcc", 1.0)
|
|
assert parse_color("pas une couleur") == ("#b3b3b3", 1.0)
|
|
|
|
|
|
def test_gray_to_hex():
|
|
assert gray_to_hex(0) == "#000000"
|
|
assert gray_to_hex(1) == "#ffffff"
|
|
assert gray_to_hex(0.5) == "#808080"
|
|
assert gray_to_hex(2) == "#ffffff"
|
|
|
|
|
|
def test_thresholds_are_regular():
|
|
assert thresholds(4) == [0.25, 0.5, 0.75]
|
|
assert thresholds(2) == [0.5]
|
|
|
|
|
|
def test_normalize_stretches_and_handles_uniform():
|
|
field = normalize(np.array([[0.2, 0.4], [0.3, 0.6]]))
|
|
assert field.min() == 0.0 and field.max() == 1.0
|
|
assert not normalize(np.full((3, 3), 0.7)).any()
|
|
|
|
|
|
def test_gaussian_blur_keeps_mean_and_uniform_field():
|
|
uniform = np.full((20, 30), 0.4)
|
|
assert np.allclose(gaussian_blur(uniform, 3.0), 0.4)
|
|
spot = np.zeros((41, 41))
|
|
spot[20, 20] = 1.0
|
|
blurred = gaussian_blur(spot, 2.0)
|
|
assert blurred.max() < 0.1
|
|
assert math.isclose(blurred.sum(), 1.0, rel_tol=1e-6)
|
|
assert np.array_equal(gaussian_blur(spot, 0), spot)
|
|
|
|
|
|
def test_box_blur_is_a_window_mean():
|
|
assert np.allclose(box_blur(np.full((9, 12), 0.3), 2), 0.3)
|
|
spot = np.zeros((9, 9))
|
|
spot[4, 4] = 1.0
|
|
blurred = box_blur(spot, 1)
|
|
assert np.allclose(blurred[3:6, 3:6], 1 / 9.0) and blurred[2, 4] == 0
|
|
assert np.array_equal(box_blur(spot, 0), spot)
|
|
|
|
|
|
def noisy_step(seed=3):
|
|
"""Deux aplats (0.25 et 0.75) separes par un bord vertical net, avec du grain."""
|
|
rng = np.random.default_rng(seed)
|
|
field = np.where(np.arange(80) < 40, 0.25, 0.75) * np.ones((60, 1))
|
|
return field + rng.normal(0, 0.03, field.shape)
|
|
|
|
|
|
def test_edge_preserving_blur_flattens_grain_but_keeps_edges():
|
|
field = noisy_step()
|
|
kept = edge_preserving_blur(field, 6)
|
|
plain = gaussian_blur(field, 6)
|
|
# Le grain des aplats est aplani...
|
|
assert kept[:, 5:30].std() < 0.01 and kept[:, 50:75].std() < 0.01
|
|
# ... mais le bord reste net, la ou le flou gaussien l'etale.
|
|
assert kept[:, 38].mean() < 0.3 and kept[:, 41].mean() > 0.7
|
|
assert plain[:, 38].mean() > 0.4 and plain[:, 41].mean() < 0.6
|
|
assert np.allclose(edge_preserving_blur(np.full((20, 20), 0.6), 4), 0.6)
|
|
assert np.array_equal(edge_preserving_blur(field, 0), field)
|
|
|
|
|
|
def test_smooth_stretches_and_selects_the_filter():
|
|
field = noisy_step()
|
|
for preserve in (False, True):
|
|
result = smooth(field, 5.0, preserve)
|
|
assert result.min() == 0.0 and result.max() == 1.0
|
|
assert np.array_equal(smooth(field, 0.0, True), normalize(field))
|
|
# Largeur de la transition entre les deux aplats, en echantillons.
|
|
def width(result):
|
|
row = result.mean(axis=0)
|
|
return int(np.count_nonzero((row > 0.1) & (row < 0.9)))
|
|
assert width(smooth(field, 5.0, True)) < width(smooth(field, 5.0, False)) / 2.0
|
|
|
|
|
|
def test_iso_layers_preserve_edges_keeps_a_thin_feature():
|
|
# Un trait sombre fin sur fond clair (une bouche, une branche de
|
|
# lunettes) : le flou gaussien l'etale, le lissage qui respecte les
|
|
# contours lui garde sa largeur.
|
|
field = np.full((80, 80), 0.8)
|
|
field[38:42, 15:65] = 0.1
|
|
field += np.random.default_rng(2).normal(0, 0.02, field.shape)
|
|
|
|
def thickness(result):
|
|
"""Epaisseur du trait a mi-hauteur, en echantillons."""
|
|
return int(np.count_nonzero(result[:, 35:45].mean(axis=1) < 0.5))
|
|
|
|
assert thickness(smooth(field, 6.0, False)) >= 10
|
|
assert thickness(smooth(field, 6.0, True)) <= 6
|
|
# Le niveau le plus sombre reste une bande fine autour du trait.
|
|
dark = iso_layers(field, levels=2, blur=6.0, preserve_edges=True)[0][2]
|
|
assert len(dark) == 1
|
|
assert dark[0][:, 1].max() - dark[0][:, 1].min() <= 7
|
|
|
|
|
|
def test_contour_of_cone_is_a_circle():
|
|
# Seuil 0.5 sur un cone de rayon 40 : cercle de rayon 20.
|
|
rings = contour_rings(cone(), 0.5)
|
|
assert len(rings) == 1
|
|
assert math.isclose(ring_area(rings[0]), math.pi * 20 ** 2, rel_tol=0.01)
|
|
radii = np.hypot(rings[0][:, 0] - 50, rings[0][:, 1] - 50)
|
|
assert np.allclose(radii, 20, atol=0.3)
|
|
|
|
|
|
def test_contour_below_threshold_has_a_hole():
|
|
# Region « sous le seuil » : le rectangle entier perce du cercle.
|
|
rings = contour_rings(cone(), 0.5, above=False)
|
|
assert len(rings) == 2
|
|
assert math.isclose(evenodd_area(rings), 100 * 100 - math.pi * 20 ** 2, rel_tol=0.01)
|
|
|
|
|
|
def test_contour_closes_along_the_image_border():
|
|
# Rampe : la region >= 0.5 est la moitie droite, fermee par les bords.
|
|
rings = contour_rings(ramp(), 0.5)
|
|
assert len(rings) == 1
|
|
ring = rings[0]
|
|
assert math.isclose(ring_area(ring), 29.5 * 39, rel_tol=1e-6)
|
|
assert math.isclose(ring[:, 0].min(), 29.5, abs_tol=1e-6)
|
|
assert ring[:, 0].max() == 59 and ring[:, 1].min() == 0 and ring[:, 1].max() == 39
|
|
|
|
|
|
def test_contour_saddle_and_empty_cases():
|
|
checker = np.array([[1.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 1.0]])
|
|
for ring in contour_rings(checker, 0.5):
|
|
assert len(ring) >= 3
|
|
assert contour_rings(np.zeros((5, 5)), 0.5) == []
|
|
assert contour_rings(np.zeros((1, 5)), 0.5) == []
|
|
full = contour_rings(np.ones((5, 7)), 0.5)
|
|
assert len(full) == 1 and math.isclose(ring_area(full[0]), 6 * 4)
|
|
|
|
|
|
def test_simplify_ring_removes_aligned_points_only():
|
|
square = []
|
|
for k in range(10):
|
|
square.append((k, 0))
|
|
for k in range(10):
|
|
square.append((10, k))
|
|
for k in range(10):
|
|
square.append((10 - k, 10))
|
|
for k in range(10):
|
|
square.append((0, 10 - k))
|
|
simplified = simplify_ring(np.array(square, dtype=float), 0.1)
|
|
assert len(simplified) == 4
|
|
assert math.isclose(ring_area(simplified), 100.0)
|
|
circle = contour_rings(cone(), 0.5)[0]
|
|
light = simplify_ring(circle, 0.3)
|
|
assert len(light) < len(circle)
|
|
assert math.isclose(ring_area(light), ring_area(circle), rel_tol=0.02)
|
|
assert len(simplify_ring(circle, 0)) == len(circle)
|
|
|
|
|
|
def test_simplify_chain_keeps_both_ends():
|
|
chain = np.array([(0, 0), (1, 0.01), (2, 0), (3, 2), (4, 4.01), (5, 6)], dtype=float)
|
|
assert simplify_chain(chain, 0.1).tolist() == [[0, 0], [2, 0], [5, 6]]
|
|
assert len(simplify_chain(chain, 0)) == 6
|
|
|
|
|
|
def test_contour_lines_split_closed_and_open():
|
|
closed, opened = contour_lines(cone(), 0.5)
|
|
assert len(closed) == 1 and opened == []
|
|
# Rampe : une ligne ouverte, du bord haut au bord bas.
|
|
closed, opened = contour_lines(ramp(), 0.5, tolerance=0.1)
|
|
assert closed == [] and len(opened) == 1
|
|
assert sorted(opened[0].tolist()) == [[29.5, 0.0], [29.5, 39.0]]
|
|
# Une ligne que la simplification aplatit sur le bord est ecartee.
|
|
bump = np.zeros((40, 60))
|
|
bump[0, 20:30] = 0.6
|
|
assert len(contour_lines(bump, 0.5)[1]) == 1
|
|
assert contour_lines(bump, 0.5, tolerance=2.0) == ([], [])
|
|
# Aire trop petite : boucle et ligne ecartees.
|
|
assert contour_lines(cone(), 0.5, min_area=2000) == ([], [])
|
|
assert contour_lines(ramp(), 0.9, min_area=500) == ([], [])
|
|
|
|
|
|
def test_iso_layers_bands_of_cone_are_rings():
|
|
layers = iso_layers(cone(), levels=4)
|
|
assert [level for level, _gray, _rings in layers] == [0, 1, 2, 3]
|
|
assert [gray for _level, gray, _rings in layers] == [0.0, 1 / 3.0, 2 / 3.0, 1.0]
|
|
areas = [evenodd_area(rings) for _level, _gray, rings in layers]
|
|
# Seuils 1/4, 1/2, 3/4 : cercles de rayons 30, 20 et 10. Chaque aplat est
|
|
# la couronne entre deux cercles, bordee des deux cotes.
|
|
disc = [math.pi * r ** 2 for r in (30, 20, 10)]
|
|
expected = [100 * 100 - disc[0], disc[0] - disc[1], disc[1] - disc[2], disc[2]]
|
|
for area, wanted in zip(areas, expected):
|
|
assert math.isclose(area, wanted, rel_tol=0.02)
|
|
assert [len(rings) for _level, _gray, rings in layers] == [2, 2, 2, 1]
|
|
assert math.isclose(sum(areas), 100 * 100, rel_tol=1e-9)
|
|
|
|
|
|
@pytest.mark.parametrize("tolerance", [0.0, 0.4])
|
|
def test_iso_layers_bands_are_the_quantized_image(tolerance):
|
|
"""Chaque point de l'image est dans l'aplat de son niveau quantifie, et
|
|
dans aucun autre : les contours sont bien les frontieres des niveaux."""
|
|
levels = 8
|
|
field = relief()
|
|
layers = iso_layers(field, levels=levels, tolerance=tolerance)
|
|
assert len(layers) == levels
|
|
rng = np.random.default_rng(5)
|
|
checked = 0
|
|
for _ in range(600):
|
|
x, y = rng.uniform(0, 89), rng.uniform(0, 59)
|
|
value = bilinear(field, x, y) * levels
|
|
if abs(value - round(value)) < 0.15:
|
|
continue # trop pres d'un seuil : interpolation et simplification
|
|
inside = [level for level, _gray, rings in layers if contains(rings, x, y)]
|
|
assert inside == [min(int(value), levels - 1)], (x, y, value)
|
|
checked += 1
|
|
assert checked > 300
|
|
|
|
|
|
def test_iso_layers_bands_never_overlap_when_small_shapes_are_dropped():
|
|
"""Avec `min_area`, des formes disparaissent, mais aucun point ne se
|
|
retrouve dans deux aplats et les grands aplats restent a leur place."""
|
|
levels = 5
|
|
rng = np.random.default_rng(7)
|
|
# Relief a grain fin : beaucoup de petites formes, y compris sur le bord.
|
|
field = normalize(gaussian_blur(rng.random((121, 161)), 2.0))
|
|
for seed in range(4):
|
|
field = normalize(field + 0.6 * relief(seed, 161, 121, 12.0))
|
|
layers = iso_layers(field, levels=levels, tolerance=0.3, min_area=0.002)
|
|
probes = np.random.default_rng(seed)
|
|
wrong = 0
|
|
for _ in range(400):
|
|
x, y = probes.uniform(0, 160), probes.uniform(0, 120)
|
|
inside = [level for level, _gray, rings in layers if contains(rings, x, y)]
|
|
assert len(inside) <= 1, (x, y, inside)
|
|
value = bilinear(field, x, y) * levels
|
|
wrong += inside != [min(int(value), levels - 1)]
|
|
# Seuls les points des petites formes ecartees changent de niveau.
|
|
assert wrong < 60, wrong
|
|
|
|
|
|
def test_iso_layers_dropped_speck_on_the_border_does_not_flip_a_band():
|
|
# Coin sombre en haut a gauche, et un eclat sombre pose sur le bord pile a
|
|
# l'oppose (coin bas-droit) : une fois l'eclat ecarte, l'aplat sombre doit
|
|
# rester le coin, pas son complementaire.
|
|
field = np.ones((60, 60))
|
|
field[:20, :20] = 0.0
|
|
field[59, 59] = 0.0
|
|
dark, light = iso_layers(field, levels=2, min_area=0.001)
|
|
assert len(dark[2]) == 1 and len(light[2]) == 1
|
|
assert math.isclose(ring_area(dark[2][0]), 19.5 ** 2, rel_tol=0.01)
|
|
assert math.isclose(ring_area(light[2][0]), 59 ** 2 - 19.5 ** 2, rel_tol=0.01)
|
|
|
|
|
|
def test_iso_layers_neighbouring_bands_share_their_border():
|
|
layers = iso_layers(relief(), levels=6, tolerance=0.4)
|
|
|
|
def interior(rings):
|
|
points = np.vstack(rings)
|
|
edge = ((points[:, 0] < 1e-6) | (points[:, 0] > 89 - 1e-6)
|
|
| (points[:, 1] < 1e-6) | (points[:, 1] > 59 - 1e-6))
|
|
return {tuple(point) for point in points[~edge].round(9).tolist()}
|
|
|
|
sets = [interior(rings) for _level, _gray, rings in layers]
|
|
for k, points in enumerate(sets):
|
|
neighbours = set()
|
|
for n in (k - 1, k + 1):
|
|
if 0 <= n < len(sets):
|
|
neighbours |= sets[n]
|
|
# Tout point de contour interieur appartient aussi a un aplat voisin.
|
|
assert points <= neighbours
|
|
assert sum(1 for points in sets if points) >= 4
|
|
|
|
|
|
def test_iso_layers_light_sheets_are_nested_discs():
|
|
layers = iso_layers(cone(), levels=4, shapes="light")
|
|
assert [level for level, _gray, _rings in layers] == [0, 1, 2, 3]
|
|
areas = [evenodd_area(rings) for _level, _gray, rings in layers]
|
|
assert math.isclose(areas[0], 100 * 100)
|
|
for area, radius in zip(areas[1:], (30, 20, 10)):
|
|
assert math.isclose(area, math.pi * radius ** 2, rel_tol=0.02)
|
|
|
|
|
|
def test_iso_layers_dark_sheets():
|
|
layers = iso_layers(cone(), levels=4, shapes="dark")
|
|
assert [level for level, _gray, _rings in layers] == [3, 2, 1, 0]
|
|
areas = [evenodd_area(rings) for _level, _gray, rings in layers]
|
|
assert math.isclose(areas[0], 100 * 100)
|
|
for area, radius in zip(areas[1:], (10, 20, 30)):
|
|
assert math.isclose(area, 100 * 100 - math.pi * radius ** 2, rel_tol=0.02)
|
|
|
|
|
|
def test_iso_layers_close_along_the_image_border():
|
|
# Rampe en 3 niveaux : trois bandes verticales, des rectangles.
|
|
layers = iso_layers(ramp(), levels=3, tolerance=0.1)
|
|
for (_level, _gray, rings), (left, right) in zip(
|
|
layers, ((0, 59 / 3.0), (59 / 3.0, 118 / 3.0), (118 / 3.0, 59))):
|
|
assert len(rings) == 1 and len(rings[0]) == 4
|
|
assert math.isclose(rings[0][:, 0].min(), left, abs_tol=1e-6)
|
|
assert math.isclose(rings[0][:, 0].max(), right, abs_tol=1e-6)
|
|
assert math.isclose(ring_area(rings[0]), (right - left) * 39, rel_tol=1e-6)
|
|
|
|
|
|
def test_iso_layers_uniform_image_gives_base_only():
|
|
layers = iso_layers(np.full((20, 30), 0.5), levels=8)
|
|
assert len(layers) == 8
|
|
assert np.array_equal(layers[0][2][0], image_ring(30, 20))
|
|
assert all(rings == [] for _level, _gray, rings in layers[1:])
|
|
|
|
|
|
def test_iso_layers_min_area_removes_specks():
|
|
field = np.zeros((80, 80))
|
|
field[10:50, 10:50] = 1.0
|
|
field[70, 70] = 1.0
|
|
assert len(iso_layers(field, levels=2)[1][2]) == 2
|
|
assert len(iso_layers(field, levels=2, min_area=0.01)[1][2]) == 1
|
|
|
|
|
|
@pytest.mark.parametrize("shapes", ["band", "light", "dark"])
|
|
def test_iso_layers_always_one_layer_per_level(shapes):
|
|
rng = np.random.default_rng(1)
|
|
field = rng.random((60, 90))
|
|
for levels in (2, 5, 16):
|
|
layers = iso_layers(field, levels=levels, blur=4.0, tolerance=0.4,
|
|
min_area=0.002, shapes=shapes)
|
|
assert len(layers) == levels
|
|
assert sorted(level for level, _gray, _rings in layers) == list(range(levels))
|
|
for _level, _gray, rings in layers:
|
|
for ring in rings:
|
|
assert len(ring) >= 3
|
|
assert ring[:, 0].min() >= 0 and ring[:, 0].max() <= 89
|
|
assert ring[:, 1].min() >= 0 and ring[:, 1].max() <= 59
|
|
|
|
|
|
def numbers(d):
|
|
"""Ensemble des points "x,y" d'une donnee de chemin."""
|
|
return set(re.findall(r"-?[\d.]+,-?[\d.]+", d))
|
|
|
|
|
|
def test_iso_boards_carry_the_outline_of_the_next_board():
|
|
# Empilees, claire devant : chaque planche porte le bord de la suivante.
|
|
boards = iso_boards(cone(), levels=4, shapes="light")
|
|
assert [board[0] for board in boards] == [0, 1, 2, 3]
|
|
for board, following in zip(boards, boards[1:]):
|
|
closed, opened = board[3]
|
|
assert opened == [] and len(closed) == 1
|
|
assert np.array_equal(closed[0], following[2][0])
|
|
assert boards[-1][3] is None
|
|
|
|
# Sombre devant : la pile part de la planche la plus claire.
|
|
boards = iso_boards(cone(), levels=4, shapes="dark")
|
|
assert [board[0] for board in boards] == [3, 2, 1, 0]
|
|
for board, following in zip(boards, boards[1:]):
|
|
closed, _opened = board[3]
|
|
# Le bord de la planche suivante est le trou qu'elle laisse au centre.
|
|
assert any(np.array_equal(closed[0], ring) for ring in following[2])
|
|
assert boards[-1][3] is None
|
|
|
|
# Aplats cote a cote : rien a empiler, donc aucun repere.
|
|
assert all(board[3] is None for board in iso_boards(cone(), levels=4, shapes="band"))
|
|
# iso_layers : les memes planches, sans les reperes.
|
|
layers = iso_layers(cone(), levels=4, shapes="light")
|
|
assert [len(layer) for layer in layers] == [3, 3, 3, 3]
|
|
assert all(np.array_equal(layer[2][0], board[2][0])
|
|
for layer, board in zip(layers, iso_boards(cone(), levels=4)))
|
|
|
|
|
|
def test_iso_boards_marks_skip_the_image_border():
|
|
# Rampe : la planche suivante touche trois bords de l'image, mais seul son
|
|
# bord interieur (une ligne ouverte) est reporte.
|
|
boards = iso_boards(ramp(), levels=3, tolerance=0.1, shapes="light")
|
|
closed, opened = boards[0][3]
|
|
assert closed == [] and len(opened) == 1
|
|
assert sorted(opened[0].tolist()) == [[59 / 3.0, 0.0], [59 / 3.0, 39.0]]
|
|
box = (0, 0, 59, 39)
|
|
mark = lines_to_d(boards[0][3], box=box)
|
|
assert mark.startswith("M ") and "Z" not in mark and mark.count("M ") == 1
|
|
# Meme trace que la decoupe de la planche suivante.
|
|
assert numbers(mark) <= numbers(rings_to_d(boards[1][2], box=box))
|
|
|
|
|
|
def test_lines_to_d_matches_the_cut_of_the_next_board():
|
|
field = relief()
|
|
boards = iso_boards(field, levels=6, tolerance=0.4, shapes="light")
|
|
box = (0, 0, 89, 59)
|
|
seen = 0
|
|
for board, following in zip(boards, boards[1:]):
|
|
mark = lines_to_d(board[3], box=box)
|
|
cut = rings_to_d(following[2], box=box)
|
|
assert numbers(mark) <= numbers(cut)
|
|
seen += bool(mark)
|
|
assert seen >= 4
|
|
assert lines_to_d(([], [])) == ""
|
|
polygon = lines_to_d(([image_ring(11, 6)], [np.array([(0, 0), (4, 3)], dtype=float)]),
|
|
smooth=False, precision=0)
|
|
assert polygon == "M 0,0 L 10,0 L 10,5 L 0,5 Z M 0,0 L 4,3"
|
|
|
|
|
|
def test_scale_lines():
|
|
closed, opened = scale_lines(([image_ring(3, 3)], [np.array([(1.0, 2.0)])]),
|
|
2.0, 3.0, 10.0, 20.0)
|
|
assert closed[0].tolist()[2] == [14, 26] and opened[0].tolist() == [[12, 26]]
|
|
|
|
|
|
def test_scale_rings():
|
|
scaled = scale_rings([image_ring(11, 6)], 2.0, 3.0, 100.0, 200.0)
|
|
assert scaled[0].tolist() == [[100, 200], [120, 200], [120, 215], [100, 215]]
|
|
|
|
|
|
def test_rings_to_d_polygon():
|
|
d = rings_to_d([image_ring(11, 6)], smooth=False, precision=0)
|
|
assert d == "M 0,0 L 10,0 L 10,5 L 0,5 Z"
|
|
assert rings_to_d([np.zeros((2, 2))]) == ""
|
|
# Transformation affine appliquee a l'ecriture : ici x' = 2x + 100, y' = y.
|
|
d = rings_to_d([image_ring(11, 6)], smooth=False, precision=0,
|
|
matrix=((2, 0, 100), (0, 1, 0)))
|
|
assert d == "M 100,0 L 120,0 L 120,5 L 100,5 Z"
|
|
|
|
|
|
def test_rings_to_d_keeps_the_image_border_straight():
|
|
box = (0, 0, 59, 39)
|
|
half = simplify_ring(contour_rings(ramp(), 0.5)[0], 0.1)
|
|
assert rings_to_d([half], smooth=True, box=box, precision=1) == "M 29.5,0.0 L 59.0,0.0 L 59.0,39.0 L 29.5,39.0 C 29.5,32.5 29.5,6.5 29.5,0.0 Z"
|
|
# Demi-disque colle au bord gauche : un cote droit, le reste en courbes
|
|
# qui ne sortent pas de l'image.
|
|
y, x = np.mgrid[0:40, 0:60]
|
|
bump = np.clip(1.0 - np.hypot(x, y - 19.5) / 30.0, 0.0, 1.0)
|
|
d = rings_to_d(contour_rings(bump, 0.5), smooth=True, box=box)
|
|
assert "L " in d and d.count("C ") > 10 and "-" not in d
|
|
circle = contour_rings(cone(), 0.5)[0]
|
|
d = rings_to_d([circle], smooth=True, box=(0, 0, 100, 100))
|
|
assert "L " not in d and d.count("C ") == len(circle)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Bout en bout (necessite inkex et Pillow)
|
|
# --------------------------------------------------------------------------
|
|
|
|
SVG = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
width="200mm" height="200mm" viewBox="0 0 200 200">
|
|
<g id="layer1" inkscape:groupmode="layer" inkscape:label="Photo"
|
|
transform="translate(10,20)">
|
|
<image id="img1" x="5" y="5" width="100" height="100"
|
|
transform="{transform}" preserveAspectRatio="none" xlink:href="{href}"/>
|
|
<image id="img2" x="120" y="5" width="50" height="50"
|
|
preserveAspectRatio="none" xlink:href="{href}"/>
|
|
<rect id="rect1" x="0" y="0" width="10" height="10"/>
|
|
</g>
|
|
<g id="layer2" inkscape:groupmode="layer" inkscape:label="Dessus"/>
|
|
</svg>
|
|
"""
|
|
|
|
|
|
def make_document(tmp_path, linked=False, transform=""):
|
|
"""Document de test : une image en cone, incorporee ou liee."""
|
|
image_module = pytest.importorskip("PIL.Image")
|
|
picture = image_module.fromarray((cone(64, 28.0) * 255).astype("uint8"), "L")
|
|
if linked:
|
|
picture.save(str(tmp_path / "cone.png"))
|
|
href = "cone.png"
|
|
else:
|
|
buffer = io.BytesIO()
|
|
picture.save(buffer, "PNG")
|
|
href = "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode()
|
|
path = tmp_path / "document.svg"
|
|
path.write_text(SVG.format(href=href, transform=transform), encoding="utf-8")
|
|
return str(path)
|
|
|
|
|
|
def run_extension(tmp_path, *args, linked=False, document=None, transform=""):
|
|
pytest.importorskip("inkex")
|
|
from gray_iso_layers import GrayIsoLayers
|
|
document = document or make_document(tmp_path, linked, transform)
|
|
out = tmp_path / "out.svg"
|
|
GrayIsoLayers().run([*args, "--output={}".format(out), document])
|
|
# inkex n'ecrit rien quand le document n'a pas change.
|
|
return out.read_text(encoding="utf-8") if out.exists() else ""
|
|
|
|
|
|
def paths(svg):
|
|
return re.findall(r"<(?:svg:)?path\b[^>]*>", svg)
|
|
|
|
|
|
def layers_of(svg):
|
|
"""Calques de premier niveau du document : (nom, nombre de chemins)."""
|
|
root = ET.fromstring(svg)
|
|
ink = "{http://www.inkscape.org/namespaces/inkscape}"
|
|
return [(child.get(ink + "label"),
|
|
sum(1 for elem in child.iter() if elem.tag.endswith("}path")))
|
|
for child in root if child.get(ink + "groupmode") == "layer"]
|
|
|
|
|
|
def test_end_to_end_side_by_side_boards(tmp_path):
|
|
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
|
|
"--shapes=band")
|
|
assert 'id="img1"' in svg
|
|
# Un calque par niveau, au premier plan, juste au-dessus du calque de
|
|
# l'image et dans l'ordre des gris.
|
|
assert layers_of(svg) == [("Photo", 0), ("Board no. 1", 1), ("Board no. 2", 1),
|
|
("Board no. 3", 1), ("Board no. 4", 1), ("Dessus", 0)]
|
|
found = paths(svg)
|
|
assert "fill:#000000" in found[0] and "fill:#ffffff" in found[3]
|
|
# Aplats : le plus sombre est le rectangle de l'image perce d'un trou,
|
|
# les suivants des couronnes, le plus clair un disque.
|
|
assert [path.count("M ") for path in found] == [2, 2, 2, 1]
|
|
assert "M 15.000,25.000 L 115.000,25.000 L 115.000,125.000 L 15.000,125.000 Z" \
|
|
in found[0]
|
|
assert all("L " not in path for path in found[1:])
|
|
assert svg.count("transform=") == 1 # celle du calque d'origine
|
|
|
|
|
|
def test_end_to_end_default(tmp_path):
|
|
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0")
|
|
# Planches a empiler : chaque calque porte sa decoupe et, sauf le
|
|
# dernier, le repere de la planche suivante.
|
|
assert layers_of(svg) == [("Photo", 0), ("Board no. 1", 2), ("Board no. 2", 2),
|
|
("Board no. 3", 2), ("Board no. 4", 1), ("Dessus", 0)]
|
|
found = paths(svg)
|
|
cuts = [path for path in found if "stroke:#ff0000" in path]
|
|
marks = [path for path in found if "stroke:#000000" in path]
|
|
assert len(cuts) == 4 and len(marks) == 3
|
|
# Decoupes : le rectangle de l'image en coordonnees du document (calque
|
|
# translate de 10, 20), puis des disques de plus en plus petits.
|
|
assert ' d="M 15.000,25.000 L 115.000,25.000 L 115.000,125.000 L 15.000,125.000 Z"' \
|
|
in cuts[0]
|
|
assert "fill:#000000" in cuts[0] and "fill:#ffffff" in cuts[3]
|
|
assert [path.count("M ") for path in cuts] == [1, 1, 1, 1]
|
|
# Reperes : un trait noir sans remplissage, au trace de la decoupe suivante.
|
|
for mark, following in zip(marks, cuts[1:]):
|
|
assert "fill:none" in mark
|
|
assert numbers(mark) and numbers(mark) <= numbers(following)
|
|
assert "Marking of board no. 2" in marks[0] and "Cut" in cuts[0]
|
|
assert svg.count("transform=") == 1 # celle du calque d'origine
|
|
|
|
|
|
def test_end_to_end_marking_options(tmp_path):
|
|
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
|
|
"--mark=false")
|
|
assert [count for name, count in layers_of(svg) if name.startswith("Board")] \
|
|
== [1, 1, 1, 1]
|
|
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
|
|
"--stroke_color={}".format(0x0000FFFF),
|
|
"--mark_color={}".format(0x00AA00FF))
|
|
found = paths(svg)
|
|
assert sum("stroke:#0000ff" in path for path in found) == 4
|
|
assert sum("stroke:#00aa00" in path for path in found) == 3
|
|
# Sombre devant : les reperes suivent la pile dans l'autre sens.
|
|
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
|
|
"--shapes=dark")
|
|
assert [count for name, count in layers_of(svg) if name.startswith("Board")] \
|
|
== [2, 2, 2, 1]
|
|
# Sans trait, pas de repere non plus.
|
|
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
|
|
"--stroke_width=0")
|
|
assert len(paths(svg)) == 4
|
|
|
|
|
|
def test_end_to_end_boards_are_numbered_from_the_back(tmp_path):
|
|
# La planche n° 1 est toujours celle du fond : la plus sombre, sauf en
|
|
# « sombre devant » ou c'est la plus claire, qui couvre toute l'image.
|
|
for shapes, back, front in (("band", "#000000", "#ffffff"),
|
|
("light", "#000000", "#ffffff"),
|
|
("dark", "#ffffff", "#000000")):
|
|
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
|
|
"--mark=false", "--shapes=" + shapes)
|
|
root = ET.fromstring(svg)
|
|
ink = "{http://www.inkscape.org/namespaces/inkscape}"
|
|
fills = {}
|
|
for layer in root:
|
|
label = layer.get(ink + "label") or ""
|
|
for elem in layer.iter():
|
|
if label.startswith("Board") and elem.tag.endswith("}path"):
|
|
fills[label] = re.search(r"fill:(#\w+)", elem.get("style")).group(1)
|
|
assert fills["Board no. 1"] == back, shapes
|
|
assert fills["Board no. 4"] == front, shapes
|
|
|
|
|
|
def test_end_to_end_one_layer_per_level_even_if_empty(tmp_path):
|
|
# Seuil d'aire enorme : seules les grandes formes restent, mais il y a
|
|
# toujours autant de calques que de niveaux.
|
|
svg = run_extension(tmp_path, "--id=img1", "--levels=6", "--blur=0",
|
|
"--min_area=10", "--mark=false")
|
|
created = [item for item in layers_of(svg) if item[0].startswith("Board")]
|
|
assert [name for name, _count in created] == [
|
|
"Board no. {}".format(k) for k in range(1, 7)]
|
|
assert created[0][1] == 1 and created[-1][1] == 0
|
|
|
|
|
|
def test_end_to_end_rotated_image(tmp_path):
|
|
svg = run_extension(tmp_path, "--id=img1", "--levels=2", "--blur=0",
|
|
"--shapes=light", transform="rotate(90)")
|
|
# rotate(90) : (x, y) -> (-y, x), puis translation du calque (10, 20).
|
|
assert ' d="M 5.000,25.000 L 5.000,125.000 L -95.000,125.000 L -95.000,25.000 Z"' \
|
|
in paths(svg)[0]
|
|
|
|
|
|
def test_end_to_end_linked_image_in_selected_group(tmp_path, monkeypatch):
|
|
# Lien relatif : resolu depuis le dossier du document.
|
|
monkeypatch.setenv("DOCUMENT_PATH", str(tmp_path / "document.svg"))
|
|
svg = run_extension(tmp_path, "--id=layer1", "--levels=3", linked=True)
|
|
# Deux images dans le groupe : elles partagent les trois calques
|
|
# (une decoupe et un repere par image, sauf sur la derniere planche).
|
|
assert layers_of(svg) == [("Photo", 0), ("Board no. 1", 4), ("Board no. 2", 4),
|
|
("Board no. 3", 2), ("Dessus", 0)]
|
|
|
|
|
|
def test_end_to_end_removes_original(tmp_path):
|
|
svg = run_extension(tmp_path, "--id=img1", "--keep_original=false")
|
|
assert 'id="img1"' not in svg
|
|
|
|
|
|
def test_end_to_end_styles(tmp_path):
|
|
svg = run_extension(tmp_path, "--id=img1", "--levels=3", "--fill_mode=color",
|
|
"--mark=false",
|
|
"--fill_color={}".format(0x336699FF),
|
|
"--stroke_color={}".format(0xCC0000FF),
|
|
"--stroke_width=1", "--unit=px")
|
|
assert all("fill:#336699" in path and "stroke:#cc0000" in path
|
|
for path in paths(svg))
|
|
svg = run_extension(tmp_path, "--id=img1", "--levels=3", "--fill_mode=none",
|
|
"--stroke_width=0")
|
|
assert all("fill:none" in path and "stroke:none" in path for path in paths(svg))
|
|
|
|
|
|
def test_end_to_end_plain_blur(tmp_path):
|
|
# Flou simple a la place du lissage qui respecte les contours : le bord
|
|
# du cone s'etale, ses disques n'ont plus le meme trace.
|
|
kept = run_extension(tmp_path, "--id=img1", "--levels=3", "--blur=8",
|
|
"--mark=false")
|
|
plain = run_extension(tmp_path, "--id=img1", "--levels=3", "--blur=8",
|
|
"--mark=false", "--edges=false")
|
|
assert len(paths(kept)) == len(paths(plain)) == 3
|
|
assert paths(kept) != paths(plain)
|
|
|
|
|
|
def test_end_to_end_without_image(tmp_path, capsys):
|
|
svg = run_extension(tmp_path, "--id=rect1")
|
|
assert "Board no. 1" not in svg
|
|
assert "Select at least one bitmap image" in capsys.readouterr().err
|
|
|
|
|
|
def test_end_to_end_broken_link(tmp_path, capsys):
|
|
pytest.importorskip("PIL.Image")
|
|
document = tmp_path / "broken.svg"
|
|
document.write_text(SVG.format(href="absente.png", transform=""),
|
|
encoding="utf-8")
|
|
svg = run_extension(tmp_path, "--id=img1", document=str(document))
|
|
assert "Board no. 1" not in svg
|
|
assert "Cannot read the image" in capsys.readouterr().err
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Traductions
|
|
# --------------------------------------------------------------------------
|
|
|
|
def test_translations_up_to_date_and_complete():
|
|
"""Chaque texte du .inx et des .py a sa traduction dans chaque catalogue."""
|
|
import gettext
|
|
import i18n
|
|
|
|
msgids = [msgid for msgid, _refs in i18n.extract()]
|
|
assert "Gray Iso-Layers" in msgids
|
|
assert "mm" not in msgids # unites marquees translatable="no"
|
|
|
|
for language in i18n.LANGUAGES:
|
|
entries = i18n.read_po(i18n.po_path(language))
|
|
missing = [m for m in msgids if not entries.get(m, ("", False))[0]]
|
|
assert not missing, "{}.po incomplet : {}".format(language, missing)
|
|
|
|
catalog = gettext.translation(i18n.DOMAIN, i18n.LOCALE_DIR, [language])
|
|
for msgid in msgids:
|
|
assert catalog.gettext(msgid) == entries[msgid][0], \
|
|
"{} : .mo a recompiler (python i18n.py)".format(language)
|
|
|
|
|
|
def test_po_roundtrip(tmp_path):
|
|
import i18n
|
|
|
|
messages = [("Simple", ["a"]), ('Quote "x" and \\ back', ["a"]),
|
|
("Two\nlines", ["a"]), ("Tab\tend\n", ["a"])]
|
|
path = str(tmp_path / "xx.po")
|
|
i18n.write_po(path, "fr", messages,
|
|
{m: ("<" + m + ">", False) for m, _r in messages})
|
|
entries = i18n.read_po(path)
|
|
for msgid, _refs in messages:
|
|
assert entries[msgid] == ("<" + msgid + ">", False)
|
|
|
|
|
|
def test_inx_matches_arguments():
|
|
"""Chaque <param> du .inx a son add_argument, avec la meme valeur par defaut."""
|
|
root = ET.parse(os.path.join(HERE, "gray_iso_layers.inx")).getroot()
|
|
params = {elem.get("name"): (elem.text or "").strip() for elem in root.iter()
|
|
if elem.tag.rsplit("}", 1)[-1] == "param"}
|
|
with open(os.path.join(HERE, "gray_iso_layers.py"), encoding="utf-8") as handle:
|
|
source = handle.read()
|
|
arguments = set(re.findall(r'add_argument\("--(\w+)"', source))
|
|
assert set(params) == arguments
|
|
|
|
pytest.importorskip("inkex")
|
|
from gray_iso_layers import GrayIsoLayers
|
|
options = GrayIsoLayers().arg_parser.parse_args([])
|
|
for name, text in params.items():
|
|
default = getattr(options, name)
|
|
if name in ("tab", "shapes", "fill_mode", "unit"):
|
|
continue # listes : la valeur par defaut est la premiere option
|
|
if isinstance(default, bool):
|
|
assert text == str(default).lower(), name
|
|
elif isinstance(default, (int, float)):
|
|
assert float(text) == float(default), name
|
|
else:
|
|
assert text == default, name
|
|
|
|
|
|
def test_inx_images_exist():
|
|
"""Les images de la boite de dialogue sont presentes (chemin relatif au .inx)."""
|
|
root = ET.parse(os.path.join(HERE, "gray_iso_layers.inx")).getroot()
|
|
for elem in root.iter():
|
|
if elem.tag.rsplit("}", 1)[-1] == "image":
|
|
assert os.path.isfile(os.path.join(HERE, elem.text.strip())), elem.text
|