hook-cv2.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. # ------------------------------------------------------------------
  2. # Copyright (c) 2020 PyInstaller Development Team.
  3. #
  4. # This file is distributed under the terms of the GNU General Public
  5. # License (version 2.0 or later).
  6. #
  7. # The full license is available in LICENSE, distributed with
  8. # this software.
  9. #
  10. # SPDX-License-Identifier: GPL-2.0-or-later
  11. # ------------------------------------------------------------------
  12. import sys
  13. import os
  14. import glob
  15. import pathlib
  16. import PyInstaller.utils.hooks as hookutils
  17. from PyInstaller import compat
  18. hiddenimports = ['numpy']
  19. # On Windows, make sure that opencv_videoio_ffmpeg*.dll is bundled
  20. binaries = []
  21. if compat.is_win:
  22. # If conda is active, look for the DLL in its library path
  23. if compat.is_conda:
  24. libdir = os.path.join(compat.base_prefix, 'Library', 'bin')
  25. pattern = os.path.join(libdir, 'opencv_videoio_ffmpeg*.dll')
  26. for f in glob.glob(pattern):
  27. binaries.append((f, '.'))
  28. # Include any DLLs from site-packages/cv2 (opencv_videoio_ffmpeg*.dll
  29. # can be found there in the PyPI version)
  30. binaries += hookutils.collect_dynamic_libs('cv2')
  31. # Collect auxiliary sub-packages, such as `cv2.gapi`, `cv2.mat_wrapper`, `cv2.misc`, and `cv2.utils`. This also
  32. # picks up submodules with valid module names, such as `cv2.config`, `cv2.load_config_py2`, and `cv2.load_config_py3`.
  33. # Therefore, filter out `cv2.load_config_py2`.
  34. hiddenimports += hookutils.collect_submodules('cv2', filter=lambda name: name != 'cv2.load_config_py2')
  35. # We also need to explicitly exclude `cv2.load_config_py2` due to it being imported in `cv2.__init__`.
  36. excludedimports = ['cv2.load_config_py2']
  37. # OpenCV loader from 4.5.4.60 requires extra config files and modules.
  38. # We need to collect `config.py` and `load_config_py3`; to improve compatibility with PyInstaller < 5.2, where
  39. # `module_collection_mode` (see below) is not implemented.
  40. # We also need to collect `config-3.py` or `config-3.X.py`, whichever is available (the former is usually
  41. # provided by PyPI wheels, while the latter seems to be used when user builds OpenCV from source).
  42. datas = hookutils.collect_data_files(
  43. 'cv2',
  44. include_py_files=True,
  45. includes=[
  46. 'config.py',
  47. f'config-{sys.version_info[0]}.{sys.version_info[1]}.py',
  48. 'config-3.py',
  49. 'load_config_py3.py',
  50. ],
  51. )
  52. # The OpenCV versions that attempt to perform module substitution via sys.path manipulation (== 4.5.4.58, >= 4.6.0.66)
  53. # do not directly import the cv2.cv2 extension anymore, so in order to ensure it is collected, we would need to add it
  54. # to hidden imports. However, when OpenCV is built by user from source, the extension is not located in the package's
  55. # root directory, but in python-3.X sub-directory, which precludes referencing via module name due to sub-directory
  56. # not being a valid subpackage name. Hence, emulate the OpenCV's loader and execute `config-3.py` or `config-3.X.py`
  57. # to obtain the search path.
  58. def find_cv2_extension(config_file):
  59. # Prepare environment
  60. PYTHON_EXTENSIONS_PATHS = []
  61. LOADER_DIR = os.path.dirname(os.path.abspath(os.path.realpath(config_file)))
  62. global_vars = globals().copy()
  63. local_vars = locals().copy()
  64. # Exec the config file
  65. with open(config_file) as fp:
  66. code = compile(fp.read(), os.path.basename(config_file), 'exec')
  67. exec(code, global_vars, local_vars)
  68. # Read the modified PYTHON_EXTENSIONS_PATHS
  69. PYTHON_EXTENSIONS_PATHS = local_vars['PYTHON_EXTENSIONS_PATHS']
  70. if not PYTHON_EXTENSIONS_PATHS:
  71. return None
  72. # Search for extension file
  73. for extension_path in PYTHON_EXTENSIONS_PATHS:
  74. extension_path = pathlib.Path(extension_path)
  75. if compat.is_win:
  76. extension_files = list(extension_path.glob('cv2*.pyd'))
  77. else:
  78. extension_files = list(extension_path.glob('cv2*.so'))
  79. if extension_files:
  80. if len(extension_files) > 1:
  81. hookutils.logger.warning("Found multiple cv2 extension candidates: %s", extension_files)
  82. extension_file = extension_files[0] # Take first (or hopefully the only one)
  83. hookutils.logger.debug("Found cv2 extension module: %s", extension_file)
  84. # Compute path relative to parent of config file (which should be the package's root)
  85. dest_dir = pathlib.Path("cv2") / extension_file.parent.relative_to(LOADER_DIR)
  86. return str(extension_file), str(dest_dir)
  87. hookutils.logger.warning(
  88. "Could not find cv2 extension module! Config file: %s, search paths: %s",
  89. config_file, PYTHON_EXTENSIONS_PATHS)
  90. return None
  91. config_file = [
  92. src_path for src_path, _ in datas
  93. if os.path.basename(src_path) in (f'config-{sys.version_info[0]}.{sys.version_info[1]}.py', 'config-3.py')
  94. ]
  95. if config_file:
  96. try:
  97. extension_info = find_cv2_extension(config_file[0])
  98. if extension_info:
  99. ext_src, ext_dst = extension_info
  100. # Due to bug in PyInstaller's TOC structure implementation (affecting PyInstaller up to latest version at
  101. # the time of writing, 5.9), we fail to properly resolve `cv2.cv2` EXTENSION entry's destination name if
  102. # we already have a BINARY entry with the same destination name. This results in verbatim `cv2.cv2` file
  103. # created in application directory in addition to the proper copy in the `cv2` sub-directoy.
  104. # Therefoe, if destination directory of the cv2 extension module is the top-level package directory, fall
  105. # back to using hiddenimports instead.
  106. if ext_dst == 'cv2':
  107. # Extension found in top-level package directory; likely a PyPI wheel.
  108. hiddenimports += ['cv2.cv2']
  109. else:
  110. # Extension found in sub-directory; use BINARY entry
  111. binaries += [extension_info]
  112. except Exception:
  113. hookutils.logger.warning("Failed to determine location of cv2 extension module!", exc_info=True)
  114. # Mark the cv2 package to be collected in source form, bypassing PyInstaller's PYZ archive and FrozenImporter. This is
  115. # necessary because recent versions of cv2 package attempt to perform module substritution via sys.path manipulation,
  116. # which is incompatible with the way that FrozenImporter works. This requires pyinstaller/pyinstaller#6945, i.e.,
  117. # PyInstaller >= 5.3. On earlier versions, the following statement does nothing, and problematic cv2 versions
  118. # (== 4.5.4.58, >= 4.6.0.66) will not work.
  119. #
  120. # Note that the collect_data_files() above is still necessary, because some of the cv2 loader's config scripts are not
  121. # valid module names (e.g., config-3.py). So the two collection approaches are complementary, and any overlap in files
  122. # (e.g., __init__.py) is handled gracefully due to PyInstaller's uniqueness constraints on collected files.
  123. module_collection_mode = 'py'
  124. # In linux PyPI opencv-python wheels, the cv2 extension is linked against Qt, and the wheel bundles a basic subset of Qt
  125. # shared libraries, plugins, and font files. This is not the case on other OSes (presumably native UI APIs are used by
  126. # OpenCV HighGUI module), nor in the headless PyPI wheels (opencv-python-headless).
  127. # The bundled Qt shared libraries should be picked up automatically due to binary dependency analysis, but we need to
  128. # collect plugins and font files from the `qt` subdirectory.
  129. if compat.is_linux:
  130. pkg_path = pathlib.Path(hookutils.get_module_file_attribute('cv2')).parent
  131. # Collect .ttf files fron fonts directory.
  132. # NOTE: since we are using glob, we can skip checks for (sub)directories' existence.
  133. qt_fonts_dir = pkg_path / 'qt' / 'fonts'
  134. datas += [
  135. (str(font_file), str(font_file.parent.relative_to(pkg_path.parent)))
  136. for font_file in qt_fonts_dir.rglob('*.ttf')
  137. ]
  138. # Collect .so files from plugins directory.
  139. qt_plugins_dir = pkg_path / 'qt' / 'plugins'
  140. binaries += [
  141. (str(plugin_file), str(plugin_file.parent.relative_to(pkg_path.parent)))
  142. for plugin_file in qt_plugins_dir.rglob('*.so')
  143. ]