2025-02-22 14:02:52 -05:00

271 lines
9.4 KiB
Python

#
# This program source code file is part of KiCad, a free EDA CAD application.
#
# Copyright (C) 2023 Mark Roszko <mark.roszko@gmail.com>
# Copyright (C) 2023 KiCad Developers
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
# MA 02110-1301, USA.
#
import difflib
import os
import pathlib
import json
import cairosvg
import logging
import subprocess
from typing import Tuple
from pathlib import Path
from PIL import Image, ImageChops, ImageFilter, ImageEnhance
import numpy as np
logger = logging.getLogger("cli_util")
Image.MAX_IMAGE_PIXELS = 800 * 1024 * 1024 // 4 # Increase limit to ~800MB uncompressed RGBA, 4bpp (~600MB RGB, 3bpp)
def kicad_cli() -> str:
if 'KICAD_CLI' in os.environ:
return os.environ.get('KICAD_CLI')
return "kicad-cli"
def run_and_capture( command: list ) -> Tuple[ str, str, int ]:
logger.info("Executing command \"%s\"", " ".join( command ))
env = {}
env.update(os.environ)
if 'KICAD_CONFIG_HOME' not in env:
if 'QA_DATA_ROOT' in env:
base_path = env.get('QA_DATA_ROOT')
else:
cwd = Path.cwd()
base_path = None
try:
if 'qa' in cwd.parts:
idx = cwd.parts.index('qa')
base_path = cwd.parents[len(cwd.parents) - idx - 1] / 'data'
except ValueError:
pass
if base_path is not None:
logger.info("Using QA data base path '%s'", str(base_path))
env['KICAD_CONFIG_HOME'] = str(base_path / 'config')
env['KICAD9_SYMBOL_DIR'] = str(base_path / 'libraries')
env['KICAD9_FOOTPRINT_DIR'] = str(base_path / 'libraries')
else:
logger.warning("Unexpected cwd '%s', tests will likely fail", cwd)
proc = subprocess.Popen( command,
stdout = subprocess.PIPE,
stderr = subprocess.PIPE,
encoding = 'utf-8',
env = env
)
out,err = proc.communicate()
if proc.returncode != 0 or len(err) != 0:
logger.info(f"command returned {proc.returncode}")
logger.info(f"stdout: {out}")
logger.info(f"stderr: {err}")
return out, err, proc.returncode
def textdiff_files( golden_filepath: str, new_filepath: str, skip: int = 0 ) -> bool:
status: bool = True
with open( golden_filepath, 'r' ) as f:
golden_lines = f.readlines()[skip:]
with open( new_filepath, 'r' ) as f:
new_lines = f.readlines()[skip:]
diff = difflib.unified_diff( golden_lines, new_lines, fromfile = golden_filepath, tofile = new_filepath )
diff_text = ''.join(list(diff))
if diff_text != "":
logger.info( "Text diff found:" )
logger.info( diff_text )
return diff_text == ""
def image_is_blank( image_path: str ) -> bool:
img = Image.open( image_path )
sum = np.sum( np.asarray( img ) )
img.close()
return sum == 0
def images_are_equal( image1_path: str, image2_path: str ) -> bool:
# Note: if modifying this function - please add new tests for it in test_utils.py
image1 = Image.open( image1_path )
image2 = Image.open( image2_path )
if image1.size != image2.size:
return False
if image1.mode != image2.mode:
return False
diff = ImageChops.difference( image1, image2 )
sum = np.sum( np.asarray( diff ) )
retval = True
if sum != 0.0:
# Images are not identical - lets allow 1 pixel error difference (for curved edges)
diff_multi_bands = diff.split()
binary_multi_bands = []
for band_diff in diff_multi_bands:
thresholded_band = band_diff.point( lambda x: x > 0 and 255 )
binary_multi_bands.append( thresholded_band.convert( "1" ) )
binary_result = binary_multi_bands[0]
for i in range( 1, len( binary_multi_bands ) ):
binary_result = ImageChops.logical_or( binary_result, binary_multi_bands[i] )
eroded_result = binary_result.copy().filter( ImageFilter.MinFilter( 3 ) ) # erode once (trim 1 pixel)
eroded_result_sum = np.sum( np.asarray( eroded_result ) )
retval = eroded_result_sum == 0
diff.close()
binary_result.close()
# Save diff
if not retval:
diff_name = image1.filename + ".DIFF_eroded_" + str( eroded_result_sum )+ ".png"
red = Image.new( "RGB", image1.size, (255,0,0))
imageEnhanced = ImageEnhance.Contrast(image1).enhance(0.3)
imageEnhanced.paste( red,mask=eroded_result)
imageEnhanced.save(diff_name)
logger.error( "Images not equal. Diff stored at '%s'", diff_name )
imageEnhanced.close()
# Cleanup
eroded_result.close()
# Cleanup
image1.close()
image2.close()
return retval
def get_png_paths( generated_path: str, source_path : str, suffix : str = "" ) -> Tuple[str, str]:
generated_stem = Path( generated_path ).stem
generated_png_path = Path( generated_path ).with_stem( generated_stem + suffix).with_suffix( ".png" )
if generated_png_path.exists():
generated_png_path.unlink() # Delete file
# store source png in same folder as generated, easier to compare
source_stem = Path( source_path ).stem
source_png_path = Path( generated_path ).with_stem( source_stem + "-source" + suffix).with_suffix( ".png" )
if source_png_path.exists():
source_png_path.unlink() # Delete file
return str( generated_png_path ), str( source_png_path )
def svgs_are_equivalent( svg_generated_path: str, svg_source_path: str, comparison_dpi: int ) -> bool:
png_generated, png_source = get_png_paths( svg_generated_path, svg_source_path )
cairosvg.svg2png( url=svg_generated_path,
write_to=png_generated,
dpi=comparison_dpi )
cairosvg.svg2png( url=svg_source_path,
write_to=png_source,
dpi=comparison_dpi )
return images_are_equal( png_generated , png_source )
def gerbers_are_equivalent( gerber_generated_path : str, gerber_source_path : str, comparison_dpi : int,
originInches : Tuple[float, float],
windowsizeInches : Tuple[float, float] ) -> bool:
# Calculate tiles required
noTilesRowsCols = np.array( [1,1] )
increaseRow = True
tileSizeInches=np.array( windowsizeInches ) / noTilesRowsCols
while( np.prod( tileSizeInches * comparison_dpi ) > Image.MAX_IMAGE_PIXELS // 2 ):
if increaseRow:
noTilesRowsCols[0]+=1
else:
noTilesRowsCols[1]+=1
increaseRow=not increaseRow
tileSizeInches=np.array( windowsizeInches ) / noTilesRowsCols
gerberGeneratedIsBlank=True
gerberSourceIsBlank=True
gerbersAreEqual=True
for row in range( noTilesRowsCols[0] ):
for col in range( noTilesRowsCols[1] ):
tileOrigin=np.array( originInches ) + ( np.array( [row,col] ) * tileSizeInches )
tile_name=f"R{row}C{col}"
png_generated, png_source = get_png_paths( gerber_generated_path, gerber_source_path, tile_name )
convert_gerber_to_png( gerber_generated_path, png_generated, comparison_dpi, tileOrigin, tileSizeInches )
convert_gerber_to_png( gerber_source_path, png_source, comparison_dpi, tileOrigin, tileSizeInches )
gerberGeneratedIsBlank = gerberGeneratedIsBlank and image_is_blank( png_generated )
gerberSourceIsBlank = gerberSourceIsBlank and image_is_blank( png_source )
if( not images_are_equal( png_generated, png_source ) ):
gerbersAreEqual = False
assert( not gerberGeneratedIsBlank )
assert( not gerberSourceIsBlank ) # make sure test case is generated correctly
return gerbersAreEqual
def convert_gerber_to_png( gerber_path : str, png_path : str, dpi : int,
originInches : Tuple[float, float],
windowsizeInches : Tuple[float, float] ):
originStr="{:.2f}".format(originInches[0]) + "x" + "{:.2f}".format(originInches[1])
windowsizeInchesStr="{:.2f}".format(windowsizeInches[0]) + "x" + "{:.2f}".format(windowsizeInches[1])
stdout, stderr, exitcode = run_and_capture(["gerbv", "--export=png", f"--dpi={dpi}",
f"--origin={originStr}",
f"--window_inch={windowsizeInchesStr}",
f"--output={png_path}",
"--foreground=#FFFFFF", "--background=#000000",
gerber_path
])
def is_gerbv_installed() -> bool:
try:
stdout, stderr, exitcode = run_and_capture(["gerbv", "--version"])
except:
return False
return exitcode == 0 and stdout is not None and stdout.startswith("gerbv version")