Skip to content

Commit 2b8a8ec

Browse files
Merge pull request #296 from GLVis/ci-misc-improvements
CI misc improvements
2 parents c8223df + a6ff194 commit 2b8a8ec

File tree

6 files changed

+200
-97
lines changed

6 files changed

+200
-97
lines changed

.github/workflows/builds.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ env:
3131
jobs:
3232
builds-and-tests:
3333
strategy:
34+
fail-fast: false
3435
matrix:
3536
os: [ubuntu-latest, macos-latest, windows-latest]
3637
target: [dbg, opt]
@@ -230,7 +231,7 @@ jobs:
230231
if: matrix.build-system == 'cmake'
231232
run: |
232233
python -m pip install --upgrade pip
233-
pip install scikit-image
234+
python -m pip install -r glvis/tests/requirements.txt
234235
235236
- name: setup Linux testing dependencies
236237
if: matrix.build-system == 'cmake' && matrix.os == 'ubuntu-latest'
@@ -242,7 +243,6 @@ jobs:
242243
run: |
243244
cd glvis && cd build
244245
xvfb-run -a ctest --verbose
245-
tar czvf test_screenshots.tar.gz tests/test.*.png
246246
247247
- name: test GLVis (cmake/mac)
248248
if: matrix.build-system == 'cmake' && matrix.os == 'macos-latest'
@@ -254,7 +254,7 @@ jobs:
254254
if: always() && matrix.build-system == 'cmake' && matrix.os != 'windows-latest'
255255
run: |
256256
cd glvis && cd build
257-
tar czvf test_screenshots.tar.gz tests/test.*.png
257+
cd tests && tar czvf ../test_screenshots.tar.gz outputs
258258
259259
- name: upload test screenshots
260260
if: always() && matrix.build-system == 'cmake' && matrix.os != 'windows-latest'

CHANGELOG

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ Version 4.3.1 (development)
1414

1515
- Fix the Mac binary build in GitHub CI.
1616

17+
- Miscellaneous CI improvements including: generating image diffs for tests,
18+
set `fail-fast: false` so that tests always run, rename artifacts to help
19+
avoid confusion, code-cleanup/light refactoring.
20+
1721

1822
Version 4.3 released on Aug 7, 2024
1923
===================================

tests/CMakeLists.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,10 @@ foreach(test_name IN LISTS stream_tests)
6161
COMMAND ${CMAKE_COMMAND} -E make_directory
6262
${CMAKE_CURRENT_SOURCE_DIR}/data/baselines/local
6363
COMMAND ${CMAKE_COMMAND} -E copy
64-
${CMAKE_CURRENT_BINARY_DIR}/test.${test_name}.saved.png
64+
${CMAKE_CURRENT_BINARY_DIR}/test.${test_name}.png
6565
${CMAKE_CURRENT_SOURCE_DIR}/data/baselines/local
6666
DEPENDS
67-
${CMAKE_CURRENT_BINARY_DIR}/test.${test_name}.saved.png
67+
${CMAKE_CURRENT_BINARY_DIR}/test.${test_name}.png
6868
VERBATIM)
6969

7070
add_dependencies(rebaseline _rebaseline_stream_${test_name})

tests/glvis_driver.py

Lines changed: 104 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -12,41 +12,21 @@
1212
import argparse
1313
import sys
1414
import os
15-
from skimage.io import imread
15+
import numpy as np
16+
from base64 import b64encode
17+
from skimage.io import imread, imsave
1618
from skimage.metrics import structural_similarity
19+
from skimage.color import rgb2gray, gray2rgb
20+
from plotly.subplots import make_subplots
21+
import plotly.graph_objects as go
22+
23+
def compare_images(
24+
baseline_file: str,
25+
output_file: str,
26+
expect_fail: bool = False,
27+
CUTOFF_SSIM: float = 0.999
28+
) -> bool:
1729

18-
# Below are key commands that are passed to the -keys command-line argument for
19-
# glvis in order to perform testing on raw mesh/grid function data (i.e. non-
20-
# streams).
21-
#
22-
# Currently not in use.
23-
test_cases = {
24-
"magnify": "*****",
25-
"axes1": "a",
26-
"axes2": "aa",
27-
"mesh1": "m",
28-
"mesh2": "mm",
29-
"cut_plane": "i",
30-
"cut_plane_rotate": "iyyyy",
31-
"cut_plane_rotate_back": "iyyyyYYYY",
32-
"cut_plane_transl": "izzzz",
33-
"cut_plane_transl_back": "izzzzZZZZ",
34-
"orient2d_1": "R",
35-
"orient2d_2": "RR",
36-
"orient2d_3": "RRR",
37-
"orient2d_4": "RRRR",
38-
"orient2d_5": "RRRRR",
39-
"orient2d_6": "RRRRRR",
40-
"orient3d": "Rr",
41-
"perspective": "j",
42-
}
43-
44-
screenshot_keys = "Sq"
45-
screenshot_file = "GLVis_s01.png"
46-
47-
cutoff_ssim = 0.999
48-
49-
def compare_images(baseline_file, output_file, expect_fail=False):
5030
# Try to open output image
5131
output_img = imread(output_file)
5232
if output_img is None:
@@ -62,7 +42,7 @@ def compare_images(baseline_file, output_file, expect_fail=False):
6242
# Compare images with SSIM metrics. For two exactly-equal images, SSIM=1.0.
6343
# We set a cutoff of 0.999 to account for possible differences in rendering.
6444
ssim = structural_similarity(baseline_img, output_img, channel_axis=2)
65-
if ssim < cutoff_ssim:
45+
if ssim < CUTOFF_SSIM:
6646
if expect_fail:
6747
print("[PASS] Differences were detected in the control case.")
6848
else:
@@ -72,92 +52,120 @@ def compare_images(baseline_file, output_file, expect_fail=False):
7252
print("[FAIL] Differences were not detected in the control case.")
7353
else:
7454
print("[PASS] Images match.")
75-
print(" actual ssim = {}, cutoff = {}".format(ssim, cutoff_ssim))
76-
return ssim >= cutoff_ssim if not expect_fail else ssim < cutoff_ssim
77-
78-
# Function to test a given glvis command with a variety of key-based commands.
79-
# Not currently in use.
80-
def test_case(exec_path, exec_args, baseline, t_group, t_name, cmd):
81-
print("Testing {0}:{1}...".format(t_group, t_name))
82-
full_screenshot_cmd = cmd + screenshot_keys
83-
cmd = "{0} {1} -k \"{2}\"".format(exec_path, exec_args, full_screenshot_cmd)
84-
print("Exec: {}".format(cmd))
85-
ret = os.system(cmd + " > /dev/null 2>&1")
86-
if ret != 0:
87-
print("[FAIL] GLVis exited with error code {}.".format(ret))
88-
return False
89-
if not os.path.exists(t_group):
90-
os.mkdir(t_group)
91-
output_name = "{0}/{1}.png".format(t_group, t_name)
55+
print(f" actual ssim = {ssim}, cutoff = {CUTOFF_SSIM}")
56+
return ssim >= CUTOFF_SSIM if not expect_fail else ssim < CUTOFF_SSIM
57+
58+
def color_distance(I1: np.array, I2: np.array) -> dict[str, np.array]:
59+
"""
60+
L2-norm in rgb space. There are better ways but this is probably good enough.
61+
"""
62+
NORM_CONSTANT = (3*(255**2))**0.5 # max distance
63+
l2norm = lambda x: np.linalg.norm(x, ord=2, axis=2)
64+
delta = l2norm(I2.astype(int)-I1.astype(int)) / NORM_CONSTANT # output is NxM [0,1]
65+
# now we scale to [0,255] and cast as uint8 so it is a "proper" image
66+
Idiff_abs = (delta * 255).astype(np.uint8)
67+
# get relative version
68+
Idiff_rel = (Idiff_abs / Idiff_abs.max() * 255).astype(np.uint8)
69+
return {'abs': Idiff_abs,
70+
'rel': Idiff_rel,}
71+
72+
def generate_image_diffs(
73+
image1_filename: str,
74+
image2_filename: str,
75+
absdiff_filename: str,
76+
reldiff_filename: str,
77+
) -> None:
78+
# Images are read as NxMx3 [uint8] arrays from [0,255]
79+
I1 = imread(image1_filename)
80+
I2 = imread(image2_filename)
81+
# Get the image diffs (abs and rel)
82+
Idiffs = color_distance(I1, I2) # output is NxM [0,1]
83+
# Save 3-channel image to file
84+
imsave(absdiff_filename, gray2rgb(Idiffs['abs']))
85+
imsave(reldiff_filename, gray2rgb(Idiffs['rel']))
86+
87+
# For the source= argument in plotly
88+
def _get_image_src(filename):
89+
with open(filename, "rb") as f:
90+
image_bytes = b64encode(f.read()).decode()
91+
return f"data:image/png;base64,{image_bytes}"
92+
93+
def image_comparison_plot(
94+
image_filenames: list[str],
95+
image_names: list[str], # for subtitles
96+
output_filename: str,
97+
):
98+
"""
99+
Illustrate results as an interactive plotly figure (html)
100+
"""
101+
assert len(image_filenames) == len(image_names)
102+
n = len(image_filenames)
103+
fig = make_subplots(rows=1, cols=n,
104+
shared_xaxes=True,
105+
shared_yaxes=True,
106+
subplot_titles=image_names)
107+
for idx, filename in enumerate(image_filenames):
108+
fig.add_trace(go.Image(source=_get_image_src(filename)), 1, idx+1)
109+
fig.update_xaxes(matches='x', showticklabels=False, showgrid=False, zeroline=False)
110+
fig.update_yaxes(matches='y', showticklabels=False, showgrid=False, zeroline=False)
111+
fig.write_html(output_filename, include_plotlyjs='cdn')
112+
113+
def test_stream(
114+
exec_path: str,
115+
exec_args: str,
116+
save_file: str,
117+
baseline: str
118+
) -> bool:
92119

93-
ret = os.system("mv {0} {1}".format(screenshot_file, output_name))
94-
if ret != 0:
95-
print("[FAIL] Could not move output image: exit code {}.".format(ret))
96-
return False
97-
98-
if baseline:
99-
baseline_name = "{0}/test.{1}.png".format(baseline, test_name)
100-
return compare_images(baseline_name, output_name)
101-
else:
102-
print("[IGNORE] No baseline exists to compare against.")
103-
return True
104-
105-
def test_stream(exec_path, exec_args, save_file, baseline):
106120
if exec_args is None:
107121
exec_args = ""
108-
test_name = os.path.basename(save_file)
109-
print("Testing {}...".format(save_file))
122+
print(f"Testing {save_file}...")
123+
test_name = os.path.basename(save_file).replace(".saved", "") # e.g. "ex3"
124+
output_dir = f"outputs/{test_name}"
125+
os.makedirs(output_dir, exist_ok=True)
110126

111127
# Create new stream file with command to screenshot and close
112128
stream_data = None
113129
with open(save_file) as in_f:
114130
stream_data = in_f.read()
115131

116-
output_name = "test.{}.png".format(test_name)
117-
output_name_fail = "test.fail.{}.png".format(test_name)
132+
output_name = f"{output_dir}/test.nominal.{test_name}.png"
133+
output_name_fail = f"{output_dir}/test.zoom.{test_name}.png"
134+
absdiff_name = f"{output_dir}/test.nominal.absdiff.{test_name}.png"
135+
reldiff_name = f"{output_dir}/test.nominal.reldiff.{test_name}.png"
118136
tmp_file = "test.saved"
119137
with open(tmp_file, 'w') as out_f:
120138
out_f.write(stream_data)
121139
out_f.write("\nwindow_size 800 600")
122-
out_f.write("\nscreenshot {}".format(output_name))
140+
out_f.write(f"\nscreenshot {output_name}")
123141
# Zooming in should create some difference in the images
124142
out_f.write("\nkeys *")
125-
out_f.write("\nscreenshot {}".format(output_name_fail))
143+
out_f.write(f"\nscreenshot {output_name_fail}")
126144
out_f.write("\nkeys q")
127145

128146
# Run GLVis with modified stream file
129-
cmd = "{0} {1} -saved {2}".format(exec_path, exec_args, tmp_file)
130-
print("Exec: {}".format(cmd))
147+
cmd = f"{exec_path} {exec_args} -saved {tmp_file}"
148+
print(f"Exec: {cmd}")
131149
ret = os.system(cmd)
132150
if ret != 0:
133-
print("[FAIL] GLVis exited with error code {}.".format(ret))
151+
print(f"[FAIL] GLVis exited with error code {ret}.")
134152
return False
135153

136154
if baseline:
137-
baseline_name = "{0}/test.{1}.png".format(baseline, test_name)
155+
baseline_name = f"{baseline}/test.{test_name}.saved.png"
138156
test_baseline = compare_images(baseline_name, output_name)
139-
test_control = compare_images(baseline_name, output_name_fail,
140-
expect_fail=True)
157+
generate_image_diffs(baseline_name, output_name, absdiff_name, reldiff_name)
158+
# Generate an interactive html plot, only if the test fails
159+
# if not test_baseline:
160+
image_comparison_plot([baseline_name, output_name, reldiff_name],
161+
["Baseline", "Test Output", "Normalized Diff"],
162+
reldiff_name.replace(".png", ".html"))
163+
test_control = compare_images(baseline_name, output_name_fail, expect_fail=True)
141164
return (test_baseline and test_control)
142165
else:
143166
print("[IGNORE] No baseline exists to compare against.")
144167
return True
145168

146-
def test_cmd(exec_path, exec_args, tgroup, baseline):
147-
try:
148-
os.remove(screenshot_file)
149-
except OSError:
150-
pass
151-
all_tests_passed = True
152-
for testname, cmds in test_cases.items():
153-
result = test_case(exec_path, exec_args, baseline, tgroup, testname, cmds)
154-
all_tests_passed = all_tests_passed and result
155-
156-
if all_tests_passed:
157-
print("All tests passed.")
158-
else:
159-
sys.exit(1)
160-
161169
if __name__ == "__main__":
162170
parser = argparse.ArgumentParser()
163171
parser.add_argument("-s", "--save_stream", help="Path to a GLVis saved stream file.")
@@ -166,9 +174,13 @@ def test_cmd(exec_path, exec_args, tgroup, baseline):
166174
parser.add_argument("-n", "--group_name", help="Name of the test group.")
167175
parser.add_argument("-b", "--baseline", help="Path to test baseline.")
168176
args = parser.parse_args()
177+
178+
# Make a directory for storing test outputs
179+
os.makedirs("outputs", exist_ok=True)
180+
# Run tests
169181
if args.save_stream is not None:
170182
result = test_stream(args.exec_cmd, args.exec_args, args.save_stream, args.baseline)
171183
if not result:
172184
sys.exit(1)
173185
else:
174-
test_cmd(args.exec_cmd, args.exec_args, args.group_name, args.baseline)
186+
raise Exception("--save_stream must be specified. test_cmd() is unused. Import from `test_cmd.py`")

tests/requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
scikit-image
2+
plotly
3+
numpy >= 1.20.0, < 2.0.0

0 commit comments

Comments
 (0)