# 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
import depth_estimate
import gray_iso_layers_depth
from gray_iso_layers_core import (
add_detail, blend_fields, box_blur, contour_lines, contour_rings, edge_preserving_blur,
gaussian_blur, gray_to_hex, guided_filter, 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, spread_levels, 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_spread_levels_gives_equal_areas():
# Rampe : les valeurs sont deja reparties, rien ne bouge.
assert np.allclose(spread_levels(ramp(200, 20)), ramp(200, 20), atol=0.01)
# Champ tasse vers le noir (carte de profondeur : fond etendu, sujet
# resserre) : a seuils reguliers, le premier niveau prend presque tout.
packed = ramp(200, 20) ** 4
assert np.array_equal(spread_levels(packed, 0), packed)
assert (packed < 0.25).mean() > 0.7
spread = spread_levels(packed)
assert spread.min() >= 0.0 and spread.max() == 1.0
for cut in (0.25, 0.5, 0.75):
assert math.isclose((spread < cut).mean(), cut, abs_tol=0.01)
# Dosage : a mi-chemin entre le champ et sa repartition.
assert np.allclose(spread_levels(packed, 0.5), (packed + spread) / 2.0)
assert not spread_levels(np.full((5, 5), 0.3)).any()
def test_iso_layers_equalize_fills_every_level():
packed = ramp(200, 20) ** 4
width = [evenodd_area(rings) / 19.0
for _level, _gray, rings in iso_layers(packed, levels=4)]
assert width[0] > 140 and width[3] < 15
width = [evenodd_area(rings) / 19.0
for _level, _gray, rings in iso_layers(packed, levels=4, equalize=1.0)]
assert np.allclose(width, 199 / 4.0, atol=2.0)
def test_guided_filter_snaps_a_blurred_edge_to_the_guide():
step = np.where(np.arange(80) < 40, 0.25, 0.75) * np.ones((60, 1))
blurred = gaussian_blur(step, 2.0)
def width(result):
"""Largeur de la transition entre les deux aplats, en echantillons."""
row = result.mean(axis=0)
return int(np.count_nonzero((row > 0.3) & (row < 0.7)))
# Le bord se recale si la fenetre est nettement plus large que le flou.
assert width(blurred) >= 6
assert width(guided_filter(step, blurred, 12)) <= 2
# Source uniforme : rien a recaler. Guide uniforme : simple lissage.
assert np.allclose(guided_filter(step, np.full(step.shape, 0.4), 8), 0.4)
assert np.allclose(guided_filter(np.zeros(step.shape), blurred, 3),
box_blur(box_blur(blurred, 3), 3))
assert np.array_equal(guided_filter(step, blurred, 0), blurred)
def test_add_detail_carves_fine_contrasts_only():
depth = ramp(80, 80)
# Fond uni, clair ou sombre : rien a graver, la profondeur ne bouge pas.
for level in (0.1, 0.9):
assert np.allclose(add_detail(depth, np.full((80, 80), level), 1.0, 4.0), depth)
# Trait sombre fin sur fond clair (une bouche) : il se creuse, et loin de
# lui la profondeur est intacte.
gray = np.full((80, 80), 0.8)
gray[38:42, 20:60] = 0.1
carved = add_detail(depth, gray, 0.5, 4.0)
assert (carved[39:41, 30:50] < depth[39:41, 30:50] - 0.2).all()
assert np.allclose(carved[:15], depth[:15], atol=1e-3)
assert carved.min() >= 0.0 and carved.max() <= 1.0
# Dosage : sans detail, la profondeur seule, etiree sur 0..1.
assert np.allclose(add_detail(depth * 3.0, gray, 0, 4.0), depth)
lighter = add_detail(depth, gray, 0.25, 4.0)
assert (lighter[39:41, 30:50] > carved[39:41, 30:50]).all()
def test_blend_fields():
gray, depth = ramp(), ramp().T[:40, :40].repeat(2, axis=1)[:, :60] * 3.0
assert np.array_equal(blend_fields(gray, depth, 0), normalize(gray))
assert np.array_equal(blend_fields(gray, depth, 1), normalize(depth))
assert np.array_equal(blend_fields(gray, depth, 7), normalize(depth))
assert np.allclose(blend_fields(gray, depth, 0.25),
0.75 * normalize(gray) + 0.25 * normalize(depth))
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)
# --------------------------------------------------------------------------
# Carte de profondeur (sans inkex ni onnxruntime : le calcul est simule)
# --------------------------------------------------------------------------
@pytest.fixture
def depth_home(tmp_path, monkeypatch):
"""Dossier de donnees factice, avec son Python et ses modeles, et un
calcul simule qui note ses appels."""
pytest.importorskip("PIL.Image")
home = tmp_path / "GrayIsoLayers"
(home / "models").mkdir(parents=True)
for name in gray_iso_layers_depth.MODELS.values():
(home / "models" / name).write_bytes(b"onnx")
monkeypatch.setattr(gray_iso_layers_depth, "data_dir", lambda: str(home))
monkeypatch.delenv("GRAY_ISO_LAYERS_PYTHON", raising=False)
python = gray_iso_layers_depth.find_python()
os.makedirs(os.path.dirname(python))
with open(python, "wb"):
pass
calls = []
def run(command, env=None, **_options):
calls.append((command, env))
np.save(command[3], np.full((4, 6), float(len(calls)), dtype=np.float32))
return type("Done", (), {"returncode": 0, "stderr": ""})()
monkeypatch.setattr(gray_iso_layers_depth.subprocess, "run", run)
return home, calls
def photo():
from PIL import Image
return Image.new("RGB", (12, 8), (200, 120, 40))
def test_depth_field_runs_the_helper_once_then_reads_the_cache(depth_home, monkeypatch):
home, calls = depth_home
monkeypatch.setenv("PYTHONPATH", "inkscape")
monkeypatch.setenv("PYTHONHOME", "inkscape")
depth = gray_iso_layers_depth.depth_field(photo(), "abc", "midas_small")
assert depth.shape == (4, 6) and (depth == 1).all()
command, env = calls[0]
assert command[0] == gray_iso_layers_depth.find_python()
assert command[1].endswith("depth_estimate.py") and os.path.isfile(command[1])
assert command[4].endswith("midas_v21_small_256.onnx")
assert command[5:] == ["--model", "midas_small"]
# Les variables du Python d'Inkscape ne passent pas a l'autre Python.
assert "PYTHONPATH" not in env and "PYTHONHOME" not in env and "PATH" in env
# Meme image, meme modele : le cache repond. Autre modele ou autre
# image : nouveau calcul.
assert (gray_iso_layers_depth.depth_field(photo(), "abc", "midas_small") == 1).all()
assert len(calls) == 1
gray_iso_layers_depth.depth_field(photo(), "abc", "depth_anything_v2_small")
gray_iso_layers_depth.depth_field(photo(), "def", "midas_small")
assert len(calls) == 3
assert len(os.listdir(str(home / "cache"))) == 3
def test_depth_field_prunes_the_cache(depth_home, monkeypatch):
home, _calls = depth_home
monkeypatch.setattr(gray_iso_layers_depth, "CACHE_LIMIT", 2)
for k, digest in enumerate(("a", "b", "c")):
gray_iso_layers_depth.depth_field(photo(), digest, "midas_small")
os.utime(str(home / "cache" / (digest + "-midas_small.npy")), (k, k))
gray_iso_layers_depth._prune(str(home / "cache"))
assert sorted(os.listdir(str(home / "cache"))) == [
"b-midas_small.npy", "c-midas_small.npy"]
def test_depth_field_reports_what_is_missing(depth_home, monkeypatch):
home, calls = depth_home
with pytest.raises(gray_iso_layers_depth.DepthUnavailable, match="unknown model"):
gray_iso_layers_depth.depth_field(photo(), "abc", "autre")
# Calcul en echec : la derniere ligne de son erreur est rapportee.
monkeypatch.setattr(
gray_iso_layers_depth.subprocess, "run",
lambda *_a, **_k: type("Done", (), {"returncode": 1,
"stderr": "Traceback\nboum\n"})())
with pytest.raises(gray_iso_layers_depth.DepthUnavailable, match="^boum$"):
gray_iso_layers_depth.depth_field(photo(), "abc", "midas_small")
def too_long(*_args, **_options):
raise gray_iso_layers_depth.subprocess.TimeoutExpired("python", 120)
monkeypatch.setattr(gray_iso_layers_depth.subprocess, "run", too_long)
with pytest.raises(gray_iso_layers_depth.DepthUnavailable, match="timed out"):
gray_iso_layers_depth.depth_field(photo(), "abc", "midas_small")
# Modele absent, puis environnement Python absent.
os.remove(str(home / "models" / "midas_v21_small_256.onnx"))
with pytest.raises(gray_iso_layers_depth.DepthUnavailable, match="midas_v21"):
gray_iso_layers_depth.depth_field(photo(), "abc", "midas_small")
os.remove(gray_iso_layers_depth.find_python())
with pytest.raises(gray_iso_layers_depth.DepthUnavailable, match="python"):
gray_iso_layers_depth.depth_field(photo(), "abc", "depth_anything_v2_small")
assert calls == [] and not (home / "cache").exists()
def test_find_python_honours_the_environment_variable(monkeypatch):
monkeypatch.setenv("GRAY_ISO_LAYERS_PYTHON", "ailleurs/python")
assert gray_iso_layers_depth.find_python() == "ailleurs/python"
monkeypatch.delenv("GRAY_ISO_LAYERS_PYTHON")
assert gray_iso_layers_depth.find_python().startswith(
gray_iso_layers_depth.data_dir())
def test_depth_estimate_preprocess_and_providers():
pytest.importorskip("PIL.Image")
assert sorted(depth_estimate.MODELS) == sorted(gray_iso_layers_depth.MODELS)
for size in depth_estimate.MODELS.values():
tensor = depth_estimate.preprocess(photo(), size)
assert tensor.shape == (1, 3, size, size) and tensor.dtype == np.float32
# Normalisation ImageNet du rouge : (200 / 255 - 0.485) / 0.229.
assert math.isclose(tensor[0, 0, 0, 0], (200 / 255.0 - 0.485) / 0.229, rel_tol=1e-4)
gpu, cpu = "DmlExecutionProvider", "CPUExecutionProvider"
assert depth_estimate.choose_providers([gpu, cpu]) == [gpu, cpu]
assert depth_estimate.choose_providers([gpu, cpu], cpu=True) == [cpu]
assert depth_estimate.choose_providers([cpu]) == [cpu]
def test_depth_estimate_real_model(tmp_path):
"""Calcul reel, quand onnxruntime et le modele sont installes."""
pytest.importorskip("onnxruntime")
pytest.importorskip("PIL.Image")
weights = os.path.join(gray_iso_layers_depth.data_dir(), "models",
gray_iso_layers_depth.MODELS["midas_small"])
if not os.path.isfile(weights):
pytest.skip("modele absent : deploy.ps1 -Depth")
source, output = str(tmp_path / "photo.png"), str(tmp_path / "depth.npy")
photo().save(source)
assert depth_estimate.main([source, output, weights, "--cpu"]) == 0
depth = np.load(output)
assert depth.shape == (256, 256) and np.isfinite(depth).all()
# --------------------------------------------------------------------------
# Bout en bout (necessite inkex et Pillow)
# --------------------------------------------------------------------------
SVG = """
"""
def make_document(tmp_path, linked=False, transform="", bits=8):
"""Document de test : une image en cone, incorporee ou liee."""
image_module = pytest.importorskip("PIL.Image")
if bits == 16:
picture = image_module.fromarray((cone(64, 28.0) * 65535).astype("uint16"))
else:
picture = image_module.fromarray((cone(64, 28.0) * 255).astype("uint8"))
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_16_bit_image(tmp_path):
# Carte de profondeur en 16 bits : memes planches que la meme image en
# 8 bits, la ou une conversion directe l'ecreterait en image blanche.
args = ("--id=img1", "--levels=4", "--blur=0", "--shapes=band")
deep = run_extension(tmp_path, *args, document=make_document(tmp_path, bits=16))
plain = run_extension(tmp_path, *args)
assert [path.count("M ") for path in paths(deep)] == [2, 2, 2, 1]
def extent(path):
"""Etendue en x des points du chemin."""
xs = [float(x) for x, _y in re.findall(r"(-?[\d.]+),(-?[\d.]+)", path)]
return min(xs), max(xs)
for a, b in zip(paths(deep), paths(plain)):
assert np.allclose(extent(a), extent(b), atol=0.5)
def test_end_to_end_equalize(tmp_path):
# Cone : les disques a seuils reguliers ont des rayons reguliers ; a
# aires egales, le niveau le plus clair s'etend.
def top_width(*extra):
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
"--shapes=band", *extra)
xs = [float(x) for x, _y in re.findall(r"(-?[\d.]+),(-?[\d.]+)", paths(svg)[3])]
return max(xs) - min(xs)
assert top_width("--equalize=100") > 1.5 * top_width()
def test_end_to_end_depth(tmp_path, monkeypatch, capsys):
pytest.importorskip("inkex")
import gray_iso_layers
calls = []
def fake(picture, digest, model):
calls.append((picture.mode, picture.size, len(digest), model))
return ramp(32, 32) # profondeur : proche a droite
monkeypatch.setattr(gray_iso_layers, "depth_field", fake)
args = ("--id=img1", "--levels=4", "--blur=0", "--shapes=band")
cone_only = run_extension(tmp_path, *args)
assert calls == [] # part de profondeur nulle : aucun calcul
assert all("L " not in path for path in paths(cone_only)[1:])
svg = run_extension(tmp_path, *args, "--depth_mix=100", "--depth_detail=0",
"--depth_model=depth_anything_v2_small")
assert calls == [("RGB", (64, 64), 40, "depth_anything_v2_small")]
# Profondeur seule : quatre bandes verticales, bordees par l'image.
assert [path.count("M ") for path in paths(svg)] == [1, 1, 1, 1]
assert all("L " in path for path in paths(svg))
# Melange : ni le cone seul, ni la rampe seule.
mixed = run_extension(tmp_path, *args, "--depth_mix=50", "--depth_detail=0")
assert paths(mixed) != paths(svg) and paths(mixed) != paths(cone_only)
# Details fins : les contrastes du cone se gravent dans la rampe.
carved = run_extension(tmp_path, *args, "--depth_mix=100")
assert paths(carved) != paths(svg) and paths(carved) != paths(cone_only)
assert capsys.readouterr().err == ""
def missing(*_args):
raise gray_iso_layers.DepthUnavailable("missing file: venv")
# Profondeur indisponible : c'est dit, et les gris servent seuls.
monkeypatch.setattr(gray_iso_layers, "depth_field", missing)
svg = run_extension(tmp_path, *args, "--depth_mix=100")
assert paths(svg) == paths(cone_only)
error = capsys.readouterr().err
assert "missing file: venv" in error and "deploy.ps1 -Depth" in error
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 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", "depth_model", "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