# 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, board_labels, 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, mask_components, mask_depth, normalize, number_lines, number_size, parse_color, region_mask, 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) def test_number_lines_draw_digits_as_segments(): closed, opened = number_lines(8, 50, 20, 10) assert closed == [] and len(opened) == 7 points = np.vstack(opened) # Chiffre de 5 x 10, centre en (50, 20). assert points.min(axis=0).tolist() == [47.5, 15] assert points.max(axis=0).tolist() == [52.5, 25] assert [len(number_lines(n, 0, 0, 1)[1]) for n in (1, 7, 4, 0)] == [2, 3, 4, 6] # Deux chiffres, encadres : un cadre ferme autour du tout. closed, opened = number_lines(12, 0, 0, 10, framed=True) assert len(opened) == 2 + 5 and len(closed) == 1 width, height = number_size(12, 10, True) assert (width, height) == (13 + 7, 10 + 7) assert np.allclose(closed[0].max(axis=0), (width / 2, height / 2)) assert np.abs(np.vstack(opened)).max(axis=0).tolist() == [6.5, 5] def test_region_mask_depth_and_components(): square = np.array([(10, 10), (30, 10), (30, 30), (10, 30)], dtype=float) hole = np.array([(18, 18), (22, 18), (22, 22), (18, 22)], dtype=float) far = np.array([(40, 5), (48, 5), (48, 9), (40, 9)], dtype=float) mask, xs, ys = region_mask([square, hole, far], 61, 41, 1.0) assert mask.shape == (len(ys), len(xs)) == (40, 60) assert mask.sum() == 20 * 20 - 4 * 4 + 8 * 4 assert mask[15, 15] and not mask[19, 19] and mask[6, 43] and not mask[0, 0] labels, count = mask_components(mask) assert count == 2 and labels[15, 15] != labels[6, 43] and labels[0, 0] == 0 depth = mask_depth(mask) assert depth[0, 0] == 0 and depth[10, 10] == 1 and depth[6, 43] == 2 # Troisieme case depuis le bord gauche du carre. assert depth[15, 12] == 3 assert not region_mask([], 61, 41, 1.0)[0].any() def test_board_labels_number_each_piece_and_each_marked_area(): boards = iso_boards(cone(), levels=4, shapes="light") # Planche n° 1 (toute l'image) : le numero 2 au creux du disque ou se pose # la planche n° 2, et son propre numero cache sous elle, a l'ecart. own, after = board_labels(boards[0][2], boards[1][2], 1, 101, 101, 6.0, 2.0) assert len(own) == 1 and len(after) == 1 (x, y, size), (nx, ny, nsize) = own[0], after[0] assert math.hypot(nx - 50, ny - 50) < 2 and nsize == 6.0 assert contains(boards[1][2], x, y) and size >= 2.0 assert math.hypot(x - nx, y - ny) > 6.0 # Derniere planche : rien ne la recouvre, son numero reste visible. own, after = board_labels(boards[3][2], None, 4, 101, 101, 6.0, 2.0) assert after == [] and len(own) == 1 assert contains(boards[3][2], own[0][0], own[0][1]) # Piece trop etroite pour un numero : pas de numero. assert board_labels(boards[3][2], None, 4, 101, 101, 30.0, 25.0) == ([], []) # Deux pieces separees : un numero chacune. left = np.array([(5, 5), (40, 5), (40, 40), (5, 40)], dtype=float) right = left + (50, 50) own, _after = board_labels([left, right], None, 3, 101, 101, 6.0, 2.0) assert sorted(round(x) // 50 for x, _y, _size in own) == [0, 1] # -------------------------------------------------------------------------- # 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" # Sans les numeros, sauf demande : les tests comptent les chemins. GrayIsoLayers().run(["--numbers=false", *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_boards_side_by_side(tmp_path): # Image de 100 x 100 en (15, 25) : cases de 105 (ecart de 5 %), 4 planches # sur 2 colonnes, la premiere case juste a droite de l'image. svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0", "--layout=side") 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 ' d="M 120.000,25.000 L 220.000,25.000 L 220.000,125.000 L 120.000,125.000 Z"' in cuts[0] def cell(path): """Case de la grille (colonne, ligne) ou tombent tous les points.""" points = re.findall(r"(-?[\d.]+),(-?[\d.]+)", path) cells = {(int((float(x) - 15) // 105), int((float(y) - 25) // 105)) for x, y in points} assert len(cells) == 1, cells return cells.pop() assert [cell(path) for path in cuts] == [(1, 0), (2, 0), (1, 1), (2, 1)] # Le repere suit sa planche : c'est le trace de la suivante, ramene # dans la case de celle-ci. assert [cell(path) for path in marks] == [(1, 0), (2, 0), (1, 1)] stacked = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0") assert paths(stacked)[0] != found[0] assert svg.count("transform=") == 1 # celle du calque d'origine # Image tournee : la grille part de son encombrement dans le document. svg = run_extension(tmp_path, "--id=img1", "--levels=2", "--blur=0", "--layout=side", "--mark=false", transform="rotate(90)") assert ' d="M 110.000,25.000 L 110.000,125.000 L 10.000,125.000 L 10.000,25.000 Z"' in paths(svg)[0] def test_end_to_end_numbers(tmp_path): svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0", "--numbers=true") # Un chemin de numeros par planche, en plus de la decoupe et du repere. assert layers_of(svg) == [("Photo", 0), ("Board no. 1", 3), ("Board no. 2", 3), ("Board no. 3", 3), ("Board no. 4", 2), ("Dessus", 0)] numbered = [path for path in paths(svg) if 'label="Numbers"' in path] assert len(numbered) == 4 for path in numbered: assert "fill:none" in path and "stroke:#000000" in path and "C " not in path # Planche n° 1 : son numero encadre (un cadre ferme, 2 segments) et le # numero 2 (5 segments) ; la derniere n'a que le sien (4 segments). assert numbered[0].count("Z") == 1 and numbered[0].count("M ") == 1 + 2 + 5 assert numbered[3].count("Z") == 1 and numbered[3].count("M ") == 1 + 4 # Sans trait, rien a graver. svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0", "--numbers=true", "--stroke_width=0") assert 'label="Numbers"' not in svg 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", "layout", "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