render_report.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. # Apache License, Version 2.0
  2. #
  3. # Compare renders or screenshots against reference versions and generate
  4. # a HTML report showing the differences, for regression testing.
  5. import glob
  6. import os
  7. import pathlib
  8. import shutil
  9. import subprocess
  10. import sys
  11. import time
  12. from . import global_report
  13. class COLORS_ANSI:
  14. RED = '\033[00;31m'
  15. GREEN = '\033[00;32m'
  16. ENDC = '\033[0m'
  17. class COLORS_DUMMY:
  18. RED = ''
  19. GREEN = ''
  20. ENDC = ''
  21. COLORS = COLORS_DUMMY
  22. def print_message(message, type=None, status=''):
  23. if type == 'SUCCESS':
  24. print(COLORS.GREEN, end="")
  25. elif type == 'FAILURE':
  26. print(COLORS.RED, end="")
  27. status_text = ...
  28. if status == 'RUN':
  29. status_text = " RUN "
  30. elif status == 'OK':
  31. status_text = " OK "
  32. elif status == 'PASSED':
  33. status_text = " PASSED "
  34. elif status == 'FAILED':
  35. status_text = " FAILED "
  36. else:
  37. status_text = status
  38. if status_text:
  39. print("[{}]" . format(status_text), end="")
  40. print(COLORS.ENDC, end="")
  41. print(" {}" . format(message))
  42. sys.stdout.flush()
  43. def blend_list(dirpath):
  44. for root, dirs, files in os.walk(dirpath):
  45. for filename in files:
  46. if filename.lower().endswith(".blend"):
  47. filepath = os.path.join(root, filename)
  48. yield filepath
  49. def test_get_name(filepath):
  50. filename = os.path.basename(filepath)
  51. return os.path.splitext(filename)[0]
  52. def test_get_images(output_dir, filepath, reference_dir):
  53. testname = test_get_name(filepath)
  54. dirpath = os.path.dirname(filepath)
  55. old_dirpath = os.path.join(dirpath, reference_dir)
  56. old_img = os.path.join(old_dirpath, testname + ".png")
  57. ref_dirpath = os.path.join(output_dir, os.path.basename(dirpath), "ref")
  58. ref_img = os.path.join(ref_dirpath, testname + ".png")
  59. os.makedirs(ref_dirpath, exist_ok=True)
  60. if os.path.exists(old_img):
  61. shutil.copy(old_img, ref_img)
  62. new_dirpath = os.path.join(output_dir, os.path.basename(dirpath))
  63. os.makedirs(new_dirpath, exist_ok=True)
  64. new_img = os.path.join(new_dirpath, testname + ".png")
  65. diff_dirpath = os.path.join(output_dir, os.path.basename(dirpath), "diff")
  66. os.makedirs(diff_dirpath, exist_ok=True)
  67. diff_img = os.path.join(diff_dirpath, testname + ".diff.png")
  68. return old_img, ref_img, new_img, diff_img
  69. class Report:
  70. __slots__ = (
  71. 'title',
  72. 'output_dir',
  73. 'reference_dir',
  74. 'idiff',
  75. 'pixelated',
  76. 'verbose',
  77. 'update',
  78. 'failed_tests',
  79. 'passed_tests',
  80. 'compare_tests',
  81. 'compare_engines'
  82. )
  83. def __init__(self, title, output_dir, idiff):
  84. self.title = title
  85. self.output_dir = output_dir
  86. self.reference_dir = 'reference_renders'
  87. self.idiff = idiff
  88. self.compare_engines = None
  89. self.pixelated = False
  90. self.verbose = os.environ.get("BLENDER_VERBOSE") is not None
  91. self.update = os.getenv('BLENDER_TEST_UPDATE') is not None
  92. if os.environ.get("BLENDER_TEST_COLOR") is not None:
  93. global COLORS, COLORS_ANSI
  94. COLORS = COLORS_ANSI
  95. self.failed_tests = ""
  96. self.passed_tests = ""
  97. self.compare_tests = ""
  98. os.makedirs(output_dir, exist_ok=True)
  99. def set_pixelated(self, pixelated):
  100. self.pixelated = pixelated
  101. def set_reference_dir(self, reference_dir):
  102. self.reference_dir = reference_dir
  103. def set_compare_engines(self, engine, other_engine):
  104. self.compare_engines = (engine, other_engine)
  105. def run(self, dirpath, blender, arguments_cb, batch=False):
  106. # Run tests and output report.
  107. dirname = os.path.basename(dirpath)
  108. ok = self._run_all_tests(dirname, dirpath, blender, arguments_cb, batch)
  109. self._write_data(dirname)
  110. self._write_html()
  111. if self.compare_engines:
  112. self._write_html(comparison=True)
  113. return ok
  114. def _write_data(self, dirname):
  115. # Write intermediate data for single test.
  116. outdir = os.path.join(self.output_dir, dirname)
  117. os.makedirs(outdir, exist_ok=True)
  118. filepath = os.path.join(outdir, "failed.data")
  119. pathlib.Path(filepath).write_text(self.failed_tests)
  120. filepath = os.path.join(outdir, "passed.data")
  121. pathlib.Path(filepath).write_text(self.passed_tests)
  122. if self.compare_engines:
  123. filepath = os.path.join(outdir, "compare.data")
  124. pathlib.Path(filepath).write_text(self.compare_tests)
  125. def _navigation_item(self, title, href, active):
  126. if active:
  127. return """<li class="breadcrumb-item active" aria-current="page">%s</li>""" % title
  128. else:
  129. return """<li class="breadcrumb-item"><a href="%s">%s</a></li>""" % (href, title)
  130. def _navigation_html(self, comparison):
  131. html = """<nav aria-label="breadcrumb"><ol class="breadcrumb">"""
  132. html += self._navigation_item("Test Reports", "../report.html", False)
  133. html += self._navigation_item(self.title, "report.html", not comparison)
  134. if self.compare_engines:
  135. compare_title = "Compare with %s" % self.compare_engines[1].capitalize()
  136. html += self._navigation_item(compare_title, "compare.html", comparison)
  137. html += """</ol></nav>"""
  138. return html
  139. def _write_html(self, comparison=False):
  140. # Gather intermediate data for all tests.
  141. if comparison:
  142. failed_data = []
  143. passed_data = sorted(glob.glob(os.path.join(self.output_dir, "*/compare.data")))
  144. else:
  145. failed_data = sorted(glob.glob(os.path.join(self.output_dir, "*/failed.data")))
  146. passed_data = sorted(glob.glob(os.path.join(self.output_dir, "*/passed.data")))
  147. failed_tests = ""
  148. passed_tests = ""
  149. for filename in failed_data:
  150. filepath = os.path.join(self.output_dir, filename)
  151. failed_tests += pathlib.Path(filepath).read_text()
  152. for filename in passed_data:
  153. filepath = os.path.join(self.output_dir, filename)
  154. passed_tests += pathlib.Path(filepath).read_text()
  155. tests_html = failed_tests + passed_tests
  156. # Write html for all tests.
  157. if self.pixelated:
  158. image_rendering = 'pixelated'
  159. else:
  160. image_rendering = 'auto'
  161. # Navigation
  162. menu = self._navigation_html(comparison)
  163. failed = len(failed_tests) > 0
  164. if failed:
  165. message = """<div class="alert alert-danger" role="alert">"""
  166. message += """Run this command to update reference images for failed tests, or create images for new tests:<br>"""
  167. message += """<tt>BLENDER_TEST_UPDATE=1 ctest -R %s</tt>""" % self.title.lower()
  168. message += """</div>"""
  169. else:
  170. message = ""
  171. if comparison:
  172. title = self.title + " Test Compare"
  173. engine_self = self.compare_engines[0].capitalize()
  174. engine_other = self.compare_engines[1].capitalize()
  175. columns_html = "<tr><th>Name</th><th>%s</th><th>%s</th>" % (engine_self, engine_other)
  176. else:
  177. title = self.title + " Test Report"
  178. columns_html = "<tr><th>Name</th><th>New</th><th>Reference</th><th>Diff</th>"
  179. html = """
  180. <html>
  181. <head>
  182. <title>{title}</title>
  183. <style>
  184. img {{ image-rendering: {image_rendering}; width: 256px; background-color: #000; }}
  185. img.render {{
  186. background-color: #fff;
  187. background-image:
  188. -moz-linear-gradient(45deg, #eee 25%, transparent 25%),
  189. -moz-linear-gradient(-45deg, #eee 25%, transparent 25%),
  190. -moz-linear-gradient(45deg, transparent 75%, #eee 75%),
  191. -moz-linear-gradient(-45deg, transparent 75%, #eee 75%);
  192. background-image:
  193. -webkit-gradient(linear, 0 100%, 100% 0, color-stop(.25, #eee), color-stop(.25, transparent)),
  194. -webkit-gradient(linear, 0 0, 100% 100%, color-stop(.25, #eee), color-stop(.25, transparent)),
  195. -webkit-gradient(linear, 0 100%, 100% 0, color-stop(.75, transparent), color-stop(.75, #eee)),
  196. -webkit-gradient(linear, 0 0, 100% 100%, color-stop(.75, transparent), color-stop(.75, #eee));
  197. -moz-background-size:50px 50px;
  198. background-size:50px 50px;
  199. -webkit-background-size:50px 51px; /* override value for shitty webkit */
  200. background-position:0 0, 25px 0, 25px -25px, 0px 25px;
  201. }}
  202. table td:first-child {{ width: 256px; }}
  203. </style>
  204. <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
  205. </head>
  206. <body>
  207. <div class="container">
  208. <br/>
  209. <h1>{title}</h1>
  210. {menu}
  211. {message}
  212. <table class="table table-striped">
  213. <thead class="thead-dark">
  214. {columns_html}
  215. </thead>
  216. {tests_html}
  217. </table>
  218. <br/>
  219. </div>
  220. </body>
  221. </html>
  222. """ . format(title=title,
  223. menu=menu,
  224. message=message,
  225. image_rendering=image_rendering,
  226. tests_html=tests_html,
  227. columns_html=columns_html)
  228. filename = "report.html" if not comparison else "compare.html"
  229. filepath = os.path.join(self.output_dir, filename)
  230. pathlib.Path(filepath).write_text(html)
  231. print_message("Report saved to: " + pathlib.Path(filepath).as_uri())
  232. # Update global report
  233. if not comparison:
  234. global_output_dir = os.path.dirname(self.output_dir)
  235. global_failed = failed if not comparison else None
  236. global_report.add(global_output_dir, "Render", self.title, filepath, global_failed)
  237. def _relative_url(self, filepath):
  238. relpath = os.path.relpath(filepath, self.output_dir)
  239. return pathlib.Path(relpath).as_posix()
  240. def _write_test_html(self, testname, filepath, error):
  241. name = test_get_name(filepath)
  242. name = name.replace('_', ' ')
  243. old_img, ref_img, new_img, diff_img = test_get_images(self.output_dir, filepath, self.reference_dir)
  244. status = error if error else ""
  245. tr_style = """ class="table-danger" """ if error else ""
  246. new_url = self._relative_url(new_img)
  247. ref_url = self._relative_url(ref_img)
  248. diff_url = self._relative_url(diff_img)
  249. test_html = """
  250. <tr{tr_style}>
  251. <td><b>{name}</b><br/>{testname}<br/>{status}</td>
  252. <td><img src="{new_url}" onmouseover="this.src='{ref_url}';" onmouseout="this.src='{new_url}';" class="render"></td>
  253. <td><img src="{ref_url}" onmouseover="this.src='{new_url}';" onmouseout="this.src='{ref_url}';" class="render"></td>
  254. <td><img src="{diff_url}"></td>
  255. </tr>""" . format(tr_style=tr_style,
  256. name=name,
  257. testname=testname,
  258. status=status,
  259. new_url=new_url,
  260. ref_url=ref_url,
  261. diff_url=diff_url)
  262. if error:
  263. self.failed_tests += test_html
  264. else:
  265. self.passed_tests += test_html
  266. if self.compare_engines:
  267. ref_url = os.path.join("..", self.compare_engines[1], new_url)
  268. test_html = """
  269. <tr{tr_style}>
  270. <td><b>{name}</b><br/>{testname}<br/>{status}</td>
  271. <td><img src="{new_url}" onmouseover="this.src='{ref_url}';" onmouseout="this.src='{new_url}';" class="render"></td>
  272. <td><img src="{ref_url}" onmouseover="this.src='{new_url}';" onmouseout="this.src='{ref_url}';" class="render"></td>
  273. </tr>""" . format(tr_style=tr_style,
  274. name=name,
  275. testname=testname,
  276. status=status,
  277. new_url=new_url,
  278. ref_url=ref_url)
  279. self.compare_tests += test_html
  280. def _diff_output(self, filepath, tmp_filepath):
  281. old_img, ref_img, new_img, diff_img = test_get_images(self.output_dir, filepath, self.reference_dir)
  282. # Create reference render directory.
  283. old_dirpath = os.path.dirname(old_img)
  284. os.makedirs(old_dirpath, exist_ok=True)
  285. # Copy temporary to new image.
  286. if os.path.exists(new_img):
  287. os.remove(new_img)
  288. if os.path.exists(tmp_filepath):
  289. shutil.copy(tmp_filepath, new_img)
  290. if os.path.exists(ref_img):
  291. # Diff images test with threshold.
  292. command = (
  293. self.idiff,
  294. "-fail", "0.016",
  295. "-failpercent", "1",
  296. ref_img,
  297. tmp_filepath,
  298. )
  299. try:
  300. subprocess.check_output(command)
  301. failed = False
  302. except subprocess.CalledProcessError as e:
  303. if self.verbose:
  304. print_message(e.output.decode("utf-8"))
  305. failed = e.returncode != 1
  306. else:
  307. if not self.update:
  308. return False
  309. failed = True
  310. if failed and self.update:
  311. # Update reference image if requested.
  312. shutil.copy(new_img, ref_img)
  313. shutil.copy(new_img, old_img)
  314. failed = False
  315. # Generate diff image.
  316. command = (
  317. self.idiff,
  318. "-o", diff_img,
  319. "-abs", "-scale", "16",
  320. ref_img,
  321. tmp_filepath
  322. )
  323. try:
  324. subprocess.check_output(command)
  325. except subprocess.CalledProcessError as e:
  326. if self.verbose:
  327. print_message(e.output.decode("utf-8"))
  328. return not failed
  329. def _run_tests(self, filepaths, blender, arguments_cb, batch):
  330. # Run multiple tests in a single Blender process since startup can be
  331. # a significant factor. In case of crashes, re-run the remaining tests.
  332. verbose = os.environ.get("BLENDER_VERBOSE") is not None
  333. remaining_filepaths = filepaths[:]
  334. errors = []
  335. while len(remaining_filepaths) > 0:
  336. command = [blender]
  337. output_filepaths = []
  338. # Construct output filepaths and command to run
  339. for filepath in remaining_filepaths:
  340. testname = test_get_name(filepath)
  341. print_message(testname, 'SUCCESS', 'RUN')
  342. base_output_filepath = os.path.join(self.output_dir, "tmp_" + testname)
  343. output_filepath = base_output_filepath + '0001.png'
  344. output_filepaths.append(output_filepath)
  345. if os.path.exists(output_filepath):
  346. os.remove(output_filepath)
  347. command.extend(arguments_cb(filepath, base_output_filepath))
  348. # Only chain multiple commands for batch
  349. if not batch:
  350. break
  351. # Run process
  352. crash = False
  353. try:
  354. output = subprocess.check_output(command)
  355. except subprocess.CalledProcessError as e:
  356. crash = True
  357. except BaseException as e:
  358. crash = True
  359. if verbose:
  360. print(" ".join(command))
  361. print(output.decode("utf-8"))
  362. # Detect missing filepaths and consider those errors
  363. for filepath, output_filepath in zip(remaining_filepaths[:], output_filepaths):
  364. remaining_filepaths.pop(0)
  365. if crash:
  366. # In case of crash, stop after missing files and re-render remaing
  367. if not os.path.exists(output_filepath):
  368. errors.append("CRASH")
  369. print_message("Crash running Blender")
  370. print_message(testname, 'FAILURE', 'FAILED')
  371. break
  372. testname = test_get_name(filepath)
  373. if not os.path.exists(output_filepath) or os.path.getsize(output_filepath) == 0:
  374. errors.append("NO OUTPUT")
  375. print_message("No render result file found")
  376. print_message(testname, 'FAILURE', 'FAILED')
  377. elif not self._diff_output(filepath, output_filepath):
  378. errors.append("VERIFY")
  379. print_message("Render result is different from reference image")
  380. print_message(testname, 'FAILURE', 'FAILED')
  381. else:
  382. errors.append(None)
  383. print_message(testname, 'SUCCESS', 'OK')
  384. if os.path.exists(output_filepath):
  385. os.remove(output_filepath)
  386. return errors
  387. def _run_all_tests(self, dirname, dirpath, blender, arguments_cb, batch):
  388. passed_tests = []
  389. failed_tests = []
  390. all_files = list(blend_list(dirpath))
  391. all_files.sort()
  392. print_message("Running {} tests from 1 test case." .
  393. format(len(all_files)),
  394. 'SUCCESS', "==========")
  395. time_start = time.time()
  396. errors = self._run_tests(all_files, blender, arguments_cb, batch)
  397. for filepath, error in zip(all_files, errors):
  398. testname = test_get_name(filepath)
  399. if error:
  400. if error == "NO_ENGINE":
  401. return False
  402. elif error == "NO_START":
  403. return False
  404. failed_tests.append(testname)
  405. else:
  406. passed_tests.append(testname)
  407. self._write_test_html(dirname, filepath, error)
  408. time_end = time.time()
  409. elapsed_ms = int((time_end - time_start) * 1000)
  410. print_message("")
  411. print_message("{} tests from 1 test case ran. ({} ms total)" .
  412. format(len(all_files), elapsed_ms),
  413. 'SUCCESS', "==========")
  414. print_message("{} tests." .
  415. format(len(passed_tests)),
  416. 'SUCCESS', 'PASSED')
  417. if failed_tests:
  418. print_message("{} tests, listed below:" .
  419. format(len(failed_tests)),
  420. 'FAILURE', 'FAILED')
  421. failed_tests.sort()
  422. for test in failed_tests:
  423. print_message("{}" . format(test), 'FAILURE', "FAILED")
  424. return not bool(failed_tests)