├── .gitattributes ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── docker-release.yml │ ├── docker.yml │ ├── integration-test.yml │ ├── windows-pack-large.yml │ ├── windows-pack.yml │ └── workflows_debug_windows.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── README_EN.md ├── api_test.py ├── benchmark.bat ├── benchmark.py ├── config.py ├── database.py ├── docker-compose.yml ├── env.py ├── gui_config.py ├── init.py ├── install.bat ├── install_ffmpeg.bat ├── install_ffmpeg.ps1 ├── main.py ├── models.py ├── process_assets.py ├── requirements.txt ├── requirements_windows.txt ├── run.bat ├── scan.py ├── search.py ├── static ├── assets │ ├── axios.min.js │ ├── axios.min.js.map │ ├── clipboard.min.js │ ├── index.css │ ├── index.full.min.js │ ├── index.full.min.js.map │ ├── index.iife.min.js │ ├── index.js │ ├── vue-i18n.global.prod.js │ └── vue.global.prod.js ├── index.html ├── locales │ ├── en.json │ └── zh.json └── login.html ├── test.png └── utils.py /.gitattributes: -------------------------------------------------------------------------------- 1 | static/assets/* linguist-vendored 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: CHN-Lee-Yumi 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | - package-ecosystem: "pip" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | -------------------------------------------------------------------------------- /.github/workflows/docker-release.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image Release CI 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up QEMU 16 | uses: docker/setup-qemu-action@v3 17 | 18 | # https://dev.to/cloudx/multi-arch-docker-images-the-easy-way-with-github-actions-4k54 19 | - name: Set up Docker Buildx 20 | id: buildx 21 | uses: docker/setup-buildx-action@v3 22 | 23 | - name: Login to Aliyun 24 | uses: docker/login-action@v3 25 | with: 26 | registry: registry.cn-hongkong.aliyuncs.com 27 | username: ${{ secrets.DOCKER_USERNAME }} 28 | password: ${{ secrets.DOCKER_PASSWORD }} 29 | 30 | - name: Login to Docker Hub 31 | uses: docker/login-action@v3 32 | with: 33 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 34 | password: ${{ secrets.DOCKER_HUB_PASSWORD }} 35 | 36 | - name: Build and push 37 | uses: docker/build-push-action@v6 38 | with: 39 | context: . 40 | platforms: linux/amd64,linux/arm64 41 | push: true 42 | tags: | 43 | yumilee/materialsearch:${{ github.event.release.tag_name }} 44 | registry.cn-hongkong.aliyuncs.com/chn-lee-yumi/materialsearch:${{ github.event.release.tag_name }} 45 | 46 | #- name: enable debug interface 47 | # uses: chn-lee-yumi/debugger-action@master 48 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | push: 5 | branches: [ "main", "testing" ] 6 | 7 | jobs: 8 | integration-test: 9 | uses: ./.github/workflows/integration-test.yml 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | needs: [integration-test] 14 | steps: 15 | 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up QEMU 20 | uses: docker/setup-qemu-action@v3 21 | 22 | # https://dev.to/cloudx/multi-arch-docker-images-the-easy-way-with-github-actions-4k54 23 | - name: Set up Docker Buildx 24 | id: buildx 25 | uses: docker/setup-buildx-action@v3 26 | 27 | - name: Login to Aliyun 28 | uses: docker/login-action@v3 29 | with: 30 | registry: registry.cn-hongkong.aliyuncs.com 31 | username: ${{ secrets.DOCKER_USERNAME }} 32 | password: ${{ secrets.DOCKER_PASSWORD }} 33 | 34 | - name: Login to Docker Hub 35 | uses: docker/login-action@v3 36 | with: 37 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 38 | password: ${{ secrets.DOCKER_HUB_PASSWORD }} 39 | 40 | - name: Build and push 41 | uses: docker/build-push-action@v6 42 | with: 43 | context: . 44 | platforms: linux/amd64,linux/arm64 45 | push: true 46 | tags: | 47 | yumilee/materialsearch:latest 48 | registry.cn-hongkong.aliyuncs.com/chn-lee-yumi/materialsearch:latest 49 | 50 | #- name: enable debug interface 51 | # uses: chn-lee-yumi/debugger-action@master 52 | -------------------------------------------------------------------------------- /.github/workflows/integration-test.yml: -------------------------------------------------------------------------------- 1 | name: Integration Test 2 | 3 | on: 4 | pull_request: 5 | branches: [ "main", "dev" ] 6 | push: 7 | branches: [ "testing" ] 8 | workflow_call: 9 | 10 | jobs: 11 | build: 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, windows-latest] # 支持 Linux 和 Windows 16 | python-version: ["3.12"] # 测试 Python 3.12 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Python 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - name: Install winget (only on Windows) 28 | if: runner.os == 'Windows' 29 | uses: Cyberboss/install-winget@v1 30 | 31 | - name: Install requirements 32 | run: | 33 | python -m pip install --upgrade pip 34 | pip install pytest 35 | if [ "${{ matrix.os }}" == "ubuntu-latest" ]; then 36 | sudo apt-get update && sudo apt-get install -y ffmpeg 37 | pip install -r requirements.txt 38 | else 39 | powershell -ExecutionPolicy Bypass ./install_ffmpeg.ps1 40 | pip install -r requirements_windows.txt 41 | fi 42 | shell: bash 43 | 44 | - name: Create .env file 45 | run: echo "ASSETS_PATH=./" > .env 46 | 47 | - name: Run main.py 48 | run: | 49 | if [ "${{ matrix.os }}" == "ubuntu-latest" ]; then 50 | nohup python main.py > run_log.txt 2>&1 & 51 | else 52 | set PYTHONUNBUFFERED=1 53 | start /min python main.py > run_log.txt 2>&1 54 | fi 55 | shell: bash 56 | 57 | - name: Read run log 58 | run: | 59 | sleep 30 60 | cat run_log.txt 61 | shell: bash 62 | 63 | - name: Test API 64 | run: | 65 | pytest 66 | echo "========== Run log: ==========" 67 | cat run_log.txt 68 | shell: bash 69 | -------------------------------------------------------------------------------- /.github/workflows/windows-pack-large.yml: -------------------------------------------------------------------------------- 1 | name: Windows Pack (Large) 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | env: 8 | HF_HOME: MaterialSearchWindows/huggingface 9 | 10 | jobs: 11 | build: 12 | runs-on: windows-latest 13 | steps: 14 | 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | path: MaterialSearchWindows 19 | 20 | - name: Download Python 21 | run: Invoke-WebRequest "https://www.python.org/ftp/python/3.12.9/python-3.12.9-embed-amd64.zip" -OutFile python.zip 22 | 23 | - name: Unzip Python 24 | run: Expand-Archive python.zip -DestinationPath MaterialSearchWindows 25 | 26 | - name: Fix python312._pth 27 | uses: DamianReeves/write-file-action@master 28 | with: 29 | path: MaterialSearchWindows/python312._pth 30 | write-mode: append 31 | contents: | 32 | import site 33 | 34 | - name: Download FFMpeg 35 | run: Invoke-WebRequest "https://github.com/GyanD/codexffmpeg/releases/download/7.1.1/ffmpeg-7.1.1-full_build.zip" -OutFile ffmpeg.zip 36 | 37 | - name: Unzip FFMpeg 38 | run: Expand-Archive ffmpeg.zip -DestinationPath . 39 | 40 | - name: Copy FFMpeg 41 | run: cp ffmpeg-7.1.1-full_build/bin/ffmpeg.exe MaterialSearchWindows 42 | 43 | - name: Download pip 44 | run: Invoke-WebRequest "https://bootstrap.pypa.io/pip/pip.pyz" -OutFile MaterialSearchWindows/pip.pyz 45 | 46 | - name: Optimise Code 47 | run: | 48 | cd MaterialSearchWindows 49 | ./python.exe -m py_compile main.py config.py database.py init.py models.py process_assets.py scan.py search.py utils.py 50 | cp __pycache__/main.cpython-312.pyc main.pyc 51 | cp __pycache__/config.cpython-312.pyc config.pyc 52 | cp __pycache__/database.cpython-312.pyc database.pyc 53 | cp __pycache__/init.cpython-312.pyc init.pyc 54 | cp __pycache__/models.cpython-312.pyc models.pyc 55 | cp __pycache__/process_assets.cpython-312.pyc process_assets.pyc 56 | cp __pycache__/scan.cpython-312.pyc scan.pyc 57 | cp __pycache__/search.cpython-312.pyc search.pyc 58 | cp __pycache__/utils.cpython-312.pyc utils.pyc 59 | rm main.py 60 | rm config.py 61 | rm database.py 62 | rm init.py 63 | rm models.py 64 | rm process_assets.py 65 | rm scan.py 66 | rm search.py 67 | rm utils.py 68 | rm -r __pycache__ 69 | cd .. 70 | 71 | - name: Install requirements 72 | run: MaterialSearchWindows/python.exe MaterialSearchWindows/pip.pyz install -r MaterialSearchWindows/requirements_windows.txt 73 | 74 | - name: Download model 75 | run: MaterialSearchWindows/python.exe -c "from transformers import AutoModelForZeroShotImageClassification, AutoProcessor; AutoModelForZeroShotImageClassification.from_pretrained('OFA-Sys/chinese-clip-vit-large-patch14-336px', use_safetensors=False); AutoProcessor.from_pretrained('OFA-Sys/chinese-clip-vit-large-patch14-336px');" 76 | 77 | - name: Create .env 78 | uses: DamianReeves/write-file-action@master 79 | with: 80 | path: MaterialSearchWindows/.env 81 | write-mode: overwrite 82 | contents: | 83 | # 注意:你用的这个整合包是大模型,显存不够16G请下载小模型的版本,否则可能无法正常运行! 84 | # 下面添加扫描路径,用逗号分隔 85 | ASSETS_PATH=C:\Users\Administrator\Pictures,C:\Users\Administrator\Videos 86 | # 如果路径或文件名包含这些字符串,就跳过,逗号分隔,不区分大小写 87 | IGNORE_STRINGS=thumb,avatar,__MACOSX,icons,cache 88 | # 图片最小宽度,小于此宽度则忽略。不需要可以改成0 89 | IMAGE_MIN_WIDTH=64 90 | # 图片最小高度,小于此高度则忽略。不需要可以改成0。 91 | IMAGE_MIN_HEIGHT=64 92 | # 视频每隔多少秒取一帧,视频展示的时候,间隔小于等于2倍FRAME_INTERVAL的算为同一个素材,同时开始时间和结束时间各延长0.5个FRAME_INTERVAL,要求为整数,最小为1 93 | FRAME_INTERVAL=2 94 | # 视频搜索出来的片段前后延长时间,单位秒,如果搜索出来的片段不完整,可以调大这个值 95 | VIDEO_EXTENSION_LENGTH=1 96 | # 支持的图片拓展名,逗号分隔,请填小写 97 | IMAGE_EXTENSIONS=.jpg,.jpeg,.png,.gif,.heic,.webp,.bmp 98 | # 支持的视频拓展名,逗号分隔,请填小写 99 | VIDEO_EXTENSIONS=.mp4,.flv,.mov,.mkv,.webm,.avi 100 | # 监听IP,如果想允许远程访问,把这个改成0.0.0.0 101 | HOST=127.0.0.1 102 | # 监听端口 103 | PORT=8085 104 | # 下面的不要改 105 | TRANSFORMERS_OFFLINE=1 106 | HF_HUB_OFFLINE=1 107 | HF_HOME=huggingface 108 | MODEL_NAME=OFA-Sys/chinese-clip-vit-large-patch14-336px 109 | 110 | - name: Create run.bat 111 | uses: DamianReeves/write-file-action@master 112 | with: 113 | path: MaterialSearchWindows/run.bat 114 | write-mode: overwrite 115 | contents: | 116 | .\python.exe main.pyc 117 | PAUSE 118 | 119 | - name: Create 使用说明.txt 120 | uses: DamianReeves/write-file-action@master 121 | with: 122 | path: MaterialSearchWindows/使用说明.txt 123 | write-mode: overwrite 124 | contents: | 125 | 右键“.env”文件进行编辑,配置扫描路径和设备,然后保存。 126 | 最后双击运行“run.bat”即可,待看到"http://127.0.0.1:8085"的输出就可以浏览器打开对应链接进行使用。 127 | 关闭“run.bat”的运行框即关闭程序。 128 | 本软件是开源软件,免费下载使用,不用付款购买,切勿上当受骗! 129 | 最新版本下载和详细使用说明请看:https://github.com/chn-lee-yumi/MaterialSearch 130 | 131 | - name: Download 7zr 132 | run: Invoke-WebRequest "https://www.7-zip.org/a/7zr.exe" -OutFile 7zr.exe 133 | 134 | - name: Compress (has bug) 135 | run: Compress-Archive -CompressionLevel NoCompression -LiteralPath MaterialSearchWindows -DestinationPath MaterialSearchWindows.zip 136 | 137 | # 下载模型的时候snapshot是链接到blobs的,但压缩的时候会将blobs和snapshot都压缩一次,导致解压后模型变成双倍大小。另外新版本huggingface会下载model.safetensors,多占一倍空间,因此要删掉。 138 | - name: Unzip (solve zip issue) 139 | run: | 140 | Expand-Archive MaterialSearchWindows.zip -DestinationPath MaterialSearch_tmp 141 | rm MaterialSearchWindows.zip 142 | rm -r MaterialSearch_tmp/MaterialSearchWindows/huggingface/hub/models--OFA-Sys--chinese-clip-vit-large-patch14-336px/blobs 143 | Get-ChildItem -Recurse -Filter "model.safetensors" | Remove-Item -Force 144 | 145 | # 改用7z压缩,提高压缩率 146 | - name: Compress (solve zip issue) 147 | run: cd MaterialSearch_tmp; ../7zr.exe a ../MaterialSearchWindowsLarge.7z MaterialSearchWindows 148 | 149 | - name: Release 150 | uses: softprops/action-gh-release@v2 151 | if: startsWith(github.ref, 'refs/tags/') 152 | with: 153 | files: MaterialSearchWindowsLarge.7z 154 | 155 | 156 | -------------------------------------------------------------------------------- /.github/workflows/windows-pack.yml: -------------------------------------------------------------------------------- 1 | name: Windows Pack 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | env: 8 | HF_HOME: MaterialSearchWindows/huggingface 9 | 10 | jobs: 11 | build: 12 | runs-on: windows-latest 13 | steps: 14 | 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | path: MaterialSearchWindows 19 | 20 | - name: Download Python 21 | run: Invoke-WebRequest "https://www.python.org/ftp/python/3.12.9/python-3.12.9-embed-amd64.zip" -OutFile python.zip 22 | 23 | - name: Unzip Python 24 | run: Expand-Archive python.zip -DestinationPath MaterialSearchWindows 25 | 26 | - name: Fix python312._pth 27 | uses: DamianReeves/write-file-action@master 28 | with: 29 | path: MaterialSearchWindows/python312._pth 30 | write-mode: append 31 | contents: | 32 | import site 33 | 34 | - name: Download FFMpeg 35 | run: Invoke-WebRequest "https://github.com/GyanD/codexffmpeg/releases/download/7.1.1/ffmpeg-7.1.1-full_build.zip" -OutFile ffmpeg.zip 36 | 37 | - name: Unzip FFMpeg 38 | run: Expand-Archive ffmpeg.zip -DestinationPath . 39 | 40 | - name: Copy FFMpeg 41 | run: cp ffmpeg-7.1.1-full_build/bin/ffmpeg.exe MaterialSearchWindows 42 | 43 | - name: Download pip 44 | run: Invoke-WebRequest "https://bootstrap.pypa.io/pip/pip.pyz" -OutFile MaterialSearchWindows/pip.pyz 45 | 46 | - name: Optimise Code 47 | run: | 48 | cd MaterialSearchWindows 49 | ./python.exe -m py_compile main.py config.py database.py init.py models.py process_assets.py scan.py search.py utils.py 50 | cp __pycache__/main.cpython-312.pyc main.pyc 51 | cp __pycache__/config.cpython-312.pyc config.pyc 52 | cp __pycache__/database.cpython-312.pyc database.pyc 53 | cp __pycache__/init.cpython-312.pyc init.pyc 54 | cp __pycache__/models.cpython-312.pyc models.pyc 55 | cp __pycache__/process_assets.cpython-312.pyc process_assets.pyc 56 | cp __pycache__/scan.cpython-312.pyc scan.pyc 57 | cp __pycache__/search.cpython-312.pyc search.pyc 58 | cp __pycache__/utils.cpython-312.pyc utils.pyc 59 | rm main.py 60 | rm config.py 61 | rm database.py 62 | rm init.py 63 | rm models.py 64 | rm process_assets.py 65 | rm scan.py 66 | rm search.py 67 | rm utils.py 68 | rm -r __pycache__ 69 | cd .. 70 | 71 | - name: Install requirements 72 | run: MaterialSearchWindows/python.exe MaterialSearchWindows/pip.pyz install -r MaterialSearchWindows/requirements_windows.txt 73 | 74 | - name: Download model 75 | run: MaterialSearchWindows/python.exe -c "from transformers import AutoModelForZeroShotImageClassification, AutoProcessor; AutoModelForZeroShotImageClassification.from_pretrained('OFA-Sys/chinese-clip-vit-base-patch16', use_safetensors=False); AutoProcessor.from_pretrained('OFA-Sys/chinese-clip-vit-base-patch16');" 76 | 77 | - name: Create .env 78 | uses: DamianReeves/write-file-action@master 79 | with: 80 | path: MaterialSearchWindows/.env 81 | write-mode: overwrite 82 | contents: | 83 | # 下面添加扫描路径,用逗号分隔 84 | ASSETS_PATH=C:\Users\Administrator\Pictures,C:\Users\Administrator\Videos 85 | # 如果路径或文件名包含这些字符串,就跳过,逗号分隔,不区分大小写 86 | IGNORE_STRINGS=thumb,avatar,__MACOSX,icons,cache 87 | # 图片最小宽度,小于此宽度则忽略。不需要可以改成0 88 | IMAGE_MIN_WIDTH=64 89 | # 图片最小高度,小于此高度则忽略。不需要可以改成0。 90 | IMAGE_MIN_HEIGHT=64 91 | # 视频每隔多少秒取一帧,视频展示的时候,间隔小于等于2倍FRAME_INTERVAL的算为同一个素材,同时开始时间和结束时间各延长0.5个FRAME_INTERVAL,要求为整数,最小为1 92 | FRAME_INTERVAL=2 93 | # 视频搜索出来的片段前后延长时间,单位秒,如果搜索出来的片段不完整,可以调大这个值 94 | VIDEO_EXTENSION_LENGTH=1 95 | # 支持的图片拓展名,逗号分隔,请填小写 96 | IMAGE_EXTENSIONS=.jpg,.jpeg,.png,.gif,.heic,.webp,.bmp 97 | # 支持的视频拓展名,逗号分隔,请填小写 98 | VIDEO_EXTENSIONS=.mp4,.flv,.mov,.mkv,.webm,.avi 99 | # 监听IP,如果想允许远程访问,把这个改成0.0.0.0 100 | HOST=127.0.0.1 101 | # 监听端口 102 | PORT=8085 103 | # 下面的不要改 104 | TRANSFORMERS_OFFLINE=1 105 | HF_HUB_OFFLINE=1 106 | HF_HOME=huggingface 107 | 108 | - name: Create run.bat 109 | uses: DamianReeves/write-file-action@master 110 | with: 111 | path: MaterialSearchWindows/run.bat 112 | write-mode: overwrite 113 | contents: | 114 | .\python.exe main.pyc 115 | PAUSE 116 | 117 | - name: Create 使用说明.txt 118 | uses: DamianReeves/write-file-action@master 119 | with: 120 | path: MaterialSearchWindows/使用说明.txt 121 | write-mode: overwrite 122 | contents: | 123 | 右键“.env”文件进行编辑,配置扫描路径和设备,然后保存。 124 | 最后双击运行“run.bat”即可,待看到"http://127.0.0.1:8085"的输出就可以浏览器打开对应链接进行使用。 125 | 关闭“run.bat”的运行框即关闭程序。 126 | 本软件是开源软件,免费下载使用,不用付款购买,切勿上当受骗! 127 | 最新版本下载和详细使用说明请看:https://github.com/chn-lee-yumi/MaterialSearch 128 | 129 | - name: Download 7zr 130 | run: Invoke-WebRequest "https://www.7-zip.org/a/7zr.exe" -OutFile 7zr.exe 131 | 132 | - name: Compress (has bug) 133 | run: Compress-Archive -CompressionLevel NoCompression -LiteralPath MaterialSearchWindows -DestinationPath MaterialSearchWindows.zip 134 | 135 | # 下载模型的时候snapshot是链接到blobs的,但压缩的时候会将blobs和snapshot都压缩一次,导致解压后模型变成双倍大小。另外新版本huggingface会下载model.safetensors,多占一倍空间,因此要删掉。 136 | - name: Unzip (solve zip issue) 137 | run: | 138 | Expand-Archive MaterialSearchWindows.zip -DestinationPath MaterialSearch_tmp 139 | rm MaterialSearchWindows.zip 140 | rm -r MaterialSearch_tmp/MaterialSearchWindows/huggingface/hub/models--OFA-Sys--chinese-clip-vit-base-patch16/blobs 141 | Get-ChildItem -Recurse -Filter "model.safetensors" | Remove-Item -Force 142 | 143 | # 改用7z压缩,提高压缩率 144 | - name: Compress (solve zip issue) 145 | run: cd MaterialSearch_tmp; ../7zr.exe a ../MaterialSearchWindows.7z MaterialSearchWindows 146 | 147 | - name: Release 148 | uses: softprops/action-gh-release@v2 149 | if: startsWith(github.ref, 'refs/tags/') 150 | with: 151 | files: MaterialSearchWindows.7z 152 | 153 | 154 | -------------------------------------------------------------------------------- /.github/workflows/workflows_debug_windows.yml: -------------------------------------------------------------------------------- 1 | name: Workflows Debug (Windows) 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build: 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | matrix: 11 | os: [windows-latest] # [ubuntu-latest, windows-latest] # 支持 Linux 和 Windows 12 | python-version: ["3.12"] # 测试 Python 3.12 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | 23 | - name: Install winget (only on Windows) 24 | if: runner.os == 'Windows' 25 | uses: Cyberboss/install-winget@v1 26 | 27 | - name: Install requirements 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install pytest 31 | if [ "${{ matrix.os }}" == "ubuntu-latest" ]; then 32 | sudo apt-get update && sudo apt-get install -y ffmpeg 33 | pip install -r requirements.txt 34 | else 35 | powershell -ExecutionPolicy Bypass ./install_ffmpeg.ps1 36 | pip install -r requirements_windows.txt 37 | fi 38 | shell: bash 39 | 40 | - name: Create .env file 41 | run: echo "ASSETS_PATH=./" > .env 42 | 43 | - name: Debug session 44 | uses: mxschmitt/action-tmate@v3 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | tmp/ 3 | sitemaps/ 4 | PexelsVideo.db 5 | PexelsPhoto.db 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | pip-wheel-metadata/ 30 | share/python-wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | MANIFEST 35 | 36 | # PyInstaller 37 | # Usually these files are written by a python script from a template 38 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 39 | *.manifest 40 | *.spec 41 | 42 | # Installer logs 43 | pip-log.txt 44 | pip-delete-this-directory.txt 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .nox/ 50 | .coverage 51 | .coverage.* 52 | .cache 53 | nosetests.xml 54 | coverage.xml 55 | *.cover 56 | *.py,cover 57 | .hypothesis/ 58 | .pytest_cache/ 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | db.sqlite3-journal 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 101 | __pypackages__/ 102 | 103 | # Celery stuff 104 | celerybeat-schedule 105 | celerybeat.pid 106 | 107 | # SageMath parsed files 108 | *.sage.py 109 | 110 | # Environments 111 | .env 112 | .venv 113 | env/ 114 | venv/ 115 | ENV/ 116 | env.bak/ 117 | venv.bak/ 118 | 119 | # Spyder project settings 120 | .spyderproject 121 | .spyproject 122 | 123 | # Rope project settings 124 | .ropeproject 125 | 126 | # mkdocs documentation 127 | /site 128 | 129 | # mypy 130 | .mypy_cache/ 131 | .dmypy.json 132 | dmypy.json 133 | 134 | # Pyre type checker 135 | .pyre/ 136 | 137 | # ignore local config 138 | config.py 139 | config.local.py 140 | 141 | # Python venv 142 | [Ss]hare 143 | # https://github.com/github/gitignore/blob/main/Global/VirtualEnv.gitignore 144 | .Python 145 | [Bb]in 146 | [Ii]nclude 147 | [Ll]ib 148 | [Ll]ib64 149 | [Ll]ocal 150 | [Ss]cripts 151 | pyvenv.cfg 152 | .venv 153 | pip-selfcheck.json 154 | env_*.py 155 | init_*.py 156 | enc_*.py 157 | index_*.js 158 | *.bk 159 | recover.sh 160 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 本Dockerfile构建对应的参数为: 2 | # MODEL_NAME = "OFA-Sys/chinese-clip-vit-base-patch16" 3 | FROM python:3.12 4 | WORKDIR /MaterialSearch/ 5 | ENV HF_HOME=/MaterialSearch/transformers/ 6 | RUN apt update && apt install -y ffmpeg && apt clean 7 | COPY requirements.txt ./ 8 | RUN pip install --no-cache-dir -r requirements.txt 9 | RUN python -c 'from transformers import AutoModelForZeroShotImageClassification, AutoProcessor; AutoModelForZeroShotImageClassification.from_pretrained("OFA-Sys/chinese-clip-vit-base-patch16"); AutoProcessor.from_pretrained("OFA-Sys/chinese-clip-vit-base-patch16");' 10 | COPY *.py ./ 11 | COPY static/ ./static/ 12 | ENV TRANSFORMERS_OFFLINE=1 13 | ENTRYPOINT ["python", "main.py"] 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MaterialSearch 本地素材搜索 2 | 3 | [**中文**](./README.md) | [**English**](./README_EN.md) 4 | 5 | 扫描本地的图片以及视频,并且可以用自然语言进行查找。 6 | 7 | 在线Demo:https://chn-lee-yumi.github.io/MaterialSearchWebDemo/ 8 | 9 | ## 功能 10 | 11 | - 文字搜图 12 | - 以图搜图 13 | - 文字搜视频(会给出符合描述的视频片段) 14 | - 以图搜视频(通过视频截图搜索所在片段) 15 | - 图文相似度计算(只是给出一个分数,用处不大) 16 | 17 | ## 部署说明 18 | 19 | ### Windows整合包 20 | 21 | B站视频教程:[点击这里,求三连支持](https://www.bilibili.com/video/BV1CKZmY4EqZ/)。 22 | 23 | 用户**互助**QQ群:1029566498(因作者精力有限,欢迎加群讨论,互相帮助。一言解惑,胜造七级浮屠;一念善行,自有千般福报。) 24 | 25 | 首先下载整合包(`MaterialSearchWindows.7z`或`MaterialSearchWindowsLarge.7z`),并使用 [7-Zip](https://www.7-zip.org/) 解压缩(注意:使用其它软件解压缩,可能会报错)。 26 | 27 | 下载方式: 28 | - [GitHub Release](https://github.com/chn-lee-yumi/MaterialSearch/releases/latest) 29 | - [夸克网盘](https://pan.quark.cn/s/ae137c439484) 30 | - [百度网盘](https://pan.baidu.com/s/1uQ8t-4mbYmcfi6FjwzdrrQ?pwd=CHNL) 提取码: CHNL 31 | 32 | 解压后请阅读里面的`使用说明.txt`。整合包会自动选择独显或核显进行加速。 33 | 34 | `MaterialSearchWindows.7z`整合包自带`OFA-Sys/chinese-clip-vit-base-patch16`模型。`MaterialSearchWindowsLarge.7z`整合包则是`OFA-Sys/chinese-clip-vit-large-patch14-336px`模型。 35 | 36 | 一般而言`OFA-Sys/chinese-clip-vit-base-patch16`模型已经足够日常使用,如果效果不佳并且显卡**显存足够大(16G以上)**,可以尝试`MaterialSearchWindowsLarge.7z`整合包。 37 | 38 | ### 通过源码部署 39 | 40 | 首先安装Python环境,然后下载本仓库代码。 41 | 42 | 注意,首次运行会自动下载模型。下载速度可能比较慢,请耐心等待。如果网络不好,模型可能会下载失败,这个时候重新执行程序即可。 43 | 44 | 1. 首次使用前需要安装依赖:`pip install -U -r requirements.txt`。Windows系统使用`requirements_windows.txt`,或双击`install.bat`。 45 | 2. 启动程序:`python main.py`,Windows系统可以双击`run.bat`。 46 | 47 | 如遇到`requirements.txt`版本依赖问题(比如某个库版本过新会导致运行报错),请提issue反馈,我会添加版本范围限制。 48 | 49 | 如果想使用"下载视频片段"的功能,需要安装`ffmpeg`。Windows系统可以运行`install_ffmpeg.bat`进行安装。 50 | 51 | ### 通过Docker部署 52 | 53 | 支持`amd64`和`arm64`,打包了默认模型(`OFA-Sys/chinese-clip-vit-base-patch16`)并且支持GPU(仅`amd64`架构的镜像支持)。 54 | 55 | 镜像地址: 56 | - [yumilee/materialsearch](https://hub.docker.com/r/yumilee/materialsearch) (DockerHub) 57 | - registry.cn-hongkong.aliyuncs.com/chn-lee-yumi/materialsearch (阿里云,推荐中国大陆用户使用) 58 | 59 | 启动镜像前,你需要准备: 60 | 61 | 1. 数据库的保存路径 62 | 2. 你的扫描路径以及打算挂载到容器内的哪个路径 63 | 3. 你可以通过修改`docker-compose.yml`里面的`environment`和`volumes`来进行配置。 64 | 4. 如果打算使用GPU,则需要取消注释`docker-compose.yml`里面的对应部分 65 | 66 | 具体请参考`docker-compose.yml`,已经写了详细注释。 67 | 68 | 最后执行`docker-compose up -d`启动容器即可。 69 | 70 | 注意: 71 | - 不推荐对容器设置内存限制,否则可能会出现奇怪的问题。比如[这个issue](https://github.com/chn-lee-yumi/MaterialSearch/issues/6)。 72 | - 容器默认设置了环境变量`TRANSFORMERS_OFFLINE=1`,也就是说运行时不会连接huggingface检查模型版本。如果你想更换容器内默认的模型,需要修改`.env`覆盖该环境变量为`TRANSFORMERS_OFFLINE=0`。 73 | 74 | ## 配置说明 75 | 76 | 所有配置都在`config.py`文件中,里面已经写了详细的注释。 77 | 78 | 建议通过环境变量或在项目根目录创建`.env`文件修改配置。如果没有配置对应的变量,则会使用`config.py`中的默认值。例如`os.getenv('HOST', '127.0.0.1')`,如果没有配置`HOST`变量,则`HOST`默认为`127.0.0.1`。 79 | 80 | `.env`文件配置示例: 81 | 82 | ```conf 83 | ASSETS_PATH=C:/Users/Administrator/Pictures,C:/Users/Administrator/Videos 84 | SKIP_PATH=C:/Users/Administrator/AppData 85 | ``` 86 | 87 | 如果你发现某些格式的图片或视频没有被扫描到,可以尝试在`IMAGE_EXTENSIONS`和`VIDEO_EXTENSIONS`增加对应的后缀。如果你发现一些支持的后缀没有被添加到代码中,欢迎提issue或pr增加。 88 | 89 | 小图片没被扫描到的话,可以调低`IMAGE_MIN_WIDTH`和`IMAGE_MIN_HEIGHT`重试。 90 | 91 | 如果想使用代理,可以添加`http_proxy`和`https_proxy`,如: 92 | 93 | ```conf 94 | http_proxy=http://127.0.0.1:7070 95 | https_proxy=http://127.0.0.1:7070 96 | ``` 97 | 98 | 注意:`ASSETS_PATH`不推荐设置为远程目录(如SMB/NFS),可能会导致扫描速度变慢。 99 | 100 | 如果想调整默认的搜索阈值,需要修改`static/index.html`文件的两行: 101 | 102 | ```text 103 | positive_threshold: 30, 104 | negative_threshold: 30, 105 | ``` 106 | 107 | 这两行分别是正向和反向搜索的阈值,默认都是30。可以根据需要进行调整。 108 | 109 | ## 问题解答 110 | 111 | 如遇问题,请先仔细阅读本文档。如果找不到答案,请在issue中搜索是否有类似问题。如果没有,可以新开一个issue,**详细说明你遇到的问题,加上你做过的尝试和思考,附上报错内容和截图,并说明你使用的系统(Windows/Linux/MacOS)和你的配置(配置在执行`main.py`的时候会打印出来)**。 112 | 113 | 本人只负责本项目的功能、代码和文档等相关问题(例如功能不正常、代码报错、文档内容有误等)。**运行环境问题请自行解决(例如:如何配置Python环境,无法使用GPU加速,如何安装ffmpeg等)。** 114 | 115 | 本人做此项目纯属“为爱发电”(也就是说,其实本人并没有义务解答你的问题)。为了提高问题解决效率,请尽量在开issue时一次性提供尽可能多的信息。如问题已解决,请记得关闭issue。一个星期无人回复的issue会被关闭。如果在被回复前已自行解决问题,推荐留下解决步骤,赠人玫瑰,手有余香。 116 | 117 | ## 硬件要求 118 | 119 | 推荐使用`amd64`或`arm64`架构的CPU。内存最低2G,但推荐最少4G内存。如果照片数量很多,推荐增加更多内存。 120 | 121 | 测试环境:J3455,8G内存。全志H6,2G内存。 122 | 123 | ## 搜索速度 124 | 125 | 在 J3455 CPU 上,1秒钟可以进行大约31000次图片匹配或25000次视频帧匹配。 126 | 127 | ## 已知问题 128 | 129 | 1. 部分视频无法在网页上显示,原因是浏览器不支持这一类型的文件(例如svq3编码的视频)。 130 | 2. 点击图片进行放大时,部分图片无法显示,原因是浏览器不支持这一类型的文件(例如tiff格式的图片)。小图可以正常显示,因为转换成缩略图的时候使用了浏览器支持的格式。大图使用的是原文件。 131 | 3. 搜视频时,如果显示的视频太多且视频体积太大,电脑可能会卡,这是正常现象。建议搜索视频时不要超过12个。 132 | 133 | ## 关于PR 134 | 135 | 欢迎提PR!不过为了避免无意义的劳动,建议先提issue讨论一下。 136 | 137 | 提PR前请确保代码已经格式化,并执行`api_test.py`确保所有测试都能通过。 138 | -------------------------------------------------------------------------------- /README_EN.md: -------------------------------------------------------------------------------- 1 | # MaterialSearch 2 | 3 | [**中文**](./README.md) | [**English**](./README_EN.md) 4 | 5 | Search local photos and videos through natural language. 6 | 7 | Online Demo:https://chn-lee-yumi.github.io/MaterialSearchWebDemo/ 8 | 9 | ## Features 10 | 11 | - Text-based image search 12 | - Image-based image search 13 | - Text-based video search (provides matching video clips based on descriptions) 14 | - Image-based video search (searches for video segments based on screenshots) 15 | - Calculation of image-text similarity (provides a score, not very useful) 16 | 17 | ## Deploy Instructions 18 | 19 | ### Deployment via Source Code 20 | 21 | First, install the Python environment and then download the code from this repository. 22 | 23 | Note that the first run will automatically download the models. The download speed may be slow, so please be patient. If the network is poor, the model download may fail. In that case, simply rerun the program. 24 | 25 | 1. Install the dependencies before first use: `pip install -U -r requirements.txt`. For Windows systems, use `requirements_windows.txt` instead, or you can double-click on `install.bat`. 26 | 2. Start the program: `python main.py`. For Windows systems, you can double-click on `run.bat`. 27 | 28 | If you encounter any issues with the version dependencies in `requirements.txt` (for example, if a library version is too new and causes errors), please provide feedback by opening an issue. I will add version range restrictions. 29 | 30 | To use the "Download Video Segments" feature, you need to install `ffmpeg`. If you are using Windows, you can run `install_ffmpeg.bat` to install. 31 | 32 | ### Deployment via Docker 33 | 34 | Supports both `amd64` and `arm64` architectures. It includes the default models (`OFA-Sys/chinese-clip-vit-base-patch16`) and supports GPU acceleration (only for `amd64` architecture). 35 | 36 | Image repositories: 37 | - [yumilee/materialsearch](https://hub.docker.com/r/yumilee/materialsearch) (DockerHub) 38 | - registry.cn-hongkong.aliyuncs.com/chn-lee-yumi/materialsearch (Aliyun, recommended for users in Mainland China) 39 | 40 | Before starting the image, you need to prepare: 41 | 42 | 1. The path to save the database 43 | 2. The scan paths on your local machine and the paths to be mounted inside the container 44 | 3. You can configure through modifying the `environment` and `volumes` sections in the `docker-compose.yml` file 45 | 4. If you plan to use GPU acceleration, uncomment the corresponding section in the `docker-compose.yml` file 46 | 47 | Please refer to the `docker-compose.yml` file for details, as it contains detailed comments. 48 | 49 | Finally, execute `docker-compose up -d` to start the container. 50 | 51 | Note: 52 | - It is not recommended to set memory limits for the container, as it may cause strange issues. For example, refer to [this issue](https://github.com/chn-lee-yumi/MaterialSearch/issues/6). 53 | - Docker image has the default environment variables `TRANSFORMERS_OFFLINE=1`, which means it won't connect to huggingface to check the model version. If you want to change the default model in the container, you have to modify `.env` and set `TRANSFORMERS_OFFLINE=0`. 54 | 55 | ## Configuration Instructions 56 | 57 | All configurations are in the `config.py` file, which contains detailed comments. 58 | 59 | It is recommended to modify the configuration through environment variables or by creating a `.env` file in the project root directory. If a corresponding variable is not configured, the default value in `config.py` will be used. For example, `os.getenv('HOST', '127.0.0.1')` will default to `127.0.0.1` if the `HOST` variable is not configured. 60 | 61 | Example `.env` file configuration: 62 | 63 | ```conf 64 | ASSETS_PATH=C:/Users/Administrator/Pictures,C:/Users/Administrator/Videos 65 | SKIP_PATH=C:/Users/Administrator/AppData 66 | ``` 67 | 68 | If you find that certain formats of images or videos are not being scanned, you can try adding the corresponding file extensions to `IMAGE_EXTENSIONS` and `VIDEO_EXTENSIONS`. If you find that some supported extensions have not been added to the code, please feel free to open an issue or submit a pull request to add them. 69 | 70 | If small images are not being scanned, you can try reducing `IMAGE_MIN_WIDTH` and `IMAGE_MIN_HEIGHT` and try again. 71 | 72 | If you want to use proxy, you can use `http_proxy` and `https_proxy`. For example: 73 | 74 | ```conf 75 | http_proxy=http://127.0.0.1:7070 76 | https_proxy=http://127.0.0.1:7070 77 | ``` 78 | 79 | Note: It is no recommended to set `ASSETS_PATH` as remote directory such as SMB/NFS, which may slow your scanning speed. 80 | 81 | To adjust the default search thresholds, you need to modify the following two lines in the `static/index.html` file: 82 | 83 | ```text 84 | positive_threshold: 30, 85 | negative_threshold: 30, 86 | ``` 87 | 88 | These lines represent the thresholds for positive and negative search respectively, both set to 30 by default. You can change them as needed. 89 | 90 | ## Troubleshooting 91 | 92 | If you encounter any issues, please read this documentation carefully first. If you cannot find an answer, search the issues to see if there are similar problems. If not, you can open a new issue and provide detailed information about the problem, including your attempted solutions and thoughts, error messages and screenshots, and the system you are using (Windows/Linux/MacOS) and the configuration (which will be printed while running `main.py`). 93 | 94 | I am only responsible for issues related to the functionality, code, and documentation of this project (such as malfunctions, code errors, and incorrect documentation). **Please resolve any runtime environment issues on your own (such as how to configure the Python environment, inability to use GPU acceleration, how to install ffmpeg, etc.).** 95 | 96 | I am doing this project purely "for the love of it" (which means, in fact, I am not obligated to answer your questions). To improve the efficiency of problem solving, please provide as much information as possible when opening an issue. If your issue has been resolved, please remember to close it. Issues that receive no response for one week will be closed. If you have resolved the issue on your own before receiving a response, it is recommended to leave the solution so that others may benefit. 97 | 98 | ## Hardware Requirements 99 | 100 | It is recommended to use a `amd64` or `arm64` architecture CPU. The minimum requirement is 2GB of memory, but it is recommended to have at least 4GB of memory. If you have a large number of photos, it is recommended to increase the amount of memory. 101 | 102 | Test environment: J3455 CPU, 8GB of memory. Allwinner H6, 2GB of memory. 103 | 104 | ## Search Speed 105 | 106 | On a J3455 CPU, approximately 31,000 image matches or 25,000 video frame matches can be performed in 1 second. 107 | 108 | ## Known Issues 109 | 110 | 1. Some videos cannot be displayed on the web page because the browser does not support that file type (e.g. videos encoded with SVQ3). 111 | 2. When you click on an image to enlarge it, some images cannot be displayed because the browser does not support this type of file (e.g. images in tiff format). Small images can be displayed normally because they are converted into thumbnails in a format supported by the browser. Large images use the original file. 112 | 3. When searching for videos, if too many videos are displayed and the video size is too large, the computer may freeze, which is normal. So it is suggested that do not select more than 12 results when you searching videos. 113 | 114 | ## About Pull Requests 115 | 116 | Pull requests are welcome! However, to avoid meaningless work, it is recommended to open an issue for discussion before submitting a pull request. 117 | 118 | Before submitting a pull request, please ensure that the code has been formatted. 119 | 120 | -------------------------------------------------------------------------------- /api_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | API测试。 3 | 测试方法: 4 | 1. 确保路径配置为:ASSETS_PATH=./ 5 | 2. 删除或重命名当前的数据库assets.db 6 | 3. 在项目跟目录执行pytest或直接执行本文件即可进行测试 7 | """ 8 | 9 | import time 10 | 11 | import pytest 12 | import requests 13 | 14 | from utils import get_hash 15 | 16 | upload_file = 'test.png' 17 | 18 | 19 | def read_file(path): 20 | with open(path, 'r', encoding='utf-8') as f: 21 | return f.read() 22 | 23 | 24 | def wait_server_ready(): 25 | for i in range(30): 26 | try: 27 | requests.get('http://127.0.0.1:8085/', timeout=1) 28 | except: 29 | time.sleep(5) 30 | continue 31 | return 32 | print("Server is not ready!") 33 | exit(1) 34 | 35 | 36 | def setup_function(): 37 | wait_server_ready() 38 | 39 | 40 | def test_index(): 41 | # 测试主页 42 | response = requests.get('http://127.0.0.1:8085/') 43 | assert response.status_code == 200 44 | # 由于不同平台的换行不一样,下面的测试可能会报错 45 | # text = response.text 46 | # index_html = read_file("static/index.html") 47 | # assert text == index_html 48 | 49 | 50 | def test_api_scan(): 51 | response = requests.get('http://127.0.0.1:8085/api/scan') 52 | assert response.status_code == 200 53 | data = response.json() 54 | assert data["status"] == "start scanning" 55 | # 马上请求第二次,应该返回正在扫描 56 | response = requests.get('http://127.0.0.1:8085/api/scan') 57 | assert response.status_code == 200 58 | data = response.json() 59 | assert data["status"] == "already scanning" 60 | 61 | 62 | def test_api_status(): 63 | response = requests.get('http://127.0.0.1:8085/api/status') 64 | assert response.status_code == 200 65 | data = response.json() 66 | assert data["status"] is True 67 | # 等待扫描完成 68 | for i in range(100): 69 | response = requests.get('http://127.0.0.1:8085/api/status') 70 | data = response.json() 71 | if data["status"] is not False: 72 | time.sleep(3) 73 | continue 74 | break 75 | assert data["status"] is False 76 | 77 | 78 | def test_api_clean_cache(): 79 | response = requests.get('http://127.0.0.1:8085/api/clean_cache') 80 | assert response.status_code == 204 81 | response = requests.post('http://127.0.0.1:8085/api/clean_cache') 82 | assert response.status_code == 204 83 | 84 | 85 | def test_api_match(): 86 | payload = { 87 | "positive": "white", 88 | "negative": "", 89 | "top_n": "6", 90 | "search_type": 0, 91 | "positive_threshold": 10, 92 | "negative_threshold": 10, 93 | "image_threshold": 85, 94 | "img_id": -1, 95 | "path": "test.png", 96 | "start_time": None, 97 | "end_time": None, 98 | } 99 | # 文字搜图 100 | response = requests.post('http://127.0.0.1:8085/api/match', json=payload) 101 | data = response.json() 102 | assert len(data) == 1 103 | assert data[0]["path"] == "test.png" 104 | assert data[0]["score"] != 0 105 | # 以图搜图 106 | with requests.session() as sess: 107 | # 测试上传图片 108 | files = {'file': ('test.png', open(upload_file, 'rb'), 'image/png')} 109 | response = sess.post('http://127.0.0.1:8085/api/upload', files=files) 110 | assert response.status_code == 200 111 | # 测试以图搜图 112 | payload["search_type"] = 1 113 | response = sess.post('http://127.0.0.1:8085/api/match', json=payload) 114 | data = response.json() 115 | assert len(data) == 1 116 | assert data[0]["path"] == "test.png" 117 | assert data[0]["score"] != 0 118 | # 测试下载图片 119 | response = requests.get('http://127.0.0.1:8085/' + data[0]["url"]) 120 | assert response.status_code == 200 121 | with open(upload_file, "rb") as f: 122 | hash_origin = get_hash(f) 123 | hash_download = get_hash(response.content) 124 | assert hash_origin == hash_download 125 | # TODO:以数据库的图搜图和视频 126 | # TODO:文字搜视频 127 | # TODO:以图搜视频 128 | # TODO:get_video 129 | 130 | 131 | # 运行测试 132 | if __name__ == '__main__': 133 | pytest.main() 134 | # TODO: 测试login和logout 135 | -------------------------------------------------------------------------------- /benchmark.bat: -------------------------------------------------------------------------------- 1 | python benchmark.py 2 | pause -------------------------------------------------------------------------------- /benchmark.py: -------------------------------------------------------------------------------- 1 | # 模型性能基准测试 2 | import time 3 | 4 | import torch 5 | from PIL import Image 6 | from transformers import AutoModelForZeroShotImageClassification, AutoProcessor 7 | 8 | from config import * 9 | import importlib.util 10 | 11 | device_list = ["cpu", "cuda", "mps", "xpu"] # 推理设备,可选cpu、cuda、mps、xpu 12 | if importlib.util.find_spec("torch_directml") is not None: # 如果支持DirectML,则加入DirectML设备 13 | import torch_directml 14 | if torch_directml.device_count() > 0: 15 | device_list.append(torch_directml.device()) 16 | 17 | image = Image.open("test.png") # 测试图片。图片大小影响速度,一般相机照片为4000x3000。图片内容不影响速度。 18 | test_times = 100 # 测试次数 19 | 20 | print("Loading models...") 21 | clip_model = AutoModelForZeroShotImageClassification.from_pretrained(MODEL_NAME) 22 | clip_processor = AutoProcessor.from_pretrained(MODEL_NAME) 23 | print("Models loaded.") 24 | 25 | # 图像处理性能基准测试 26 | print("*" * 50) 27 | print("开始进行图像处理性能基准测试。用时越短越好。") 28 | min_time = float('inf') 29 | recommend_device = '' 30 | for device in device_list: 31 | try: 32 | clip_model = clip_model.to(torch.device(device)) 33 | except (AssertionError, RuntimeError): 34 | print(f"该平台不支持{device},已跳过。") 35 | continue 36 | t0 = time.time() 37 | for i in range(test_times): 38 | inputs = clip_processor(images=[image] * SCAN_PROCESS_BATCH_SIZE, return_tensors="pt", padding=True)['pixel_values'].to(torch.device(device)) 39 | feature = clip_model.get_image_features(inputs).detach().cpu().numpy() 40 | cost_time = time.time() - t0 41 | print(f"设备:{device} 用时:{cost_time}秒") 42 | if cost_time < min_time: 43 | min_time = cost_time 44 | recommend_device = device 45 | print(f"图像处理建议使用设备:{recommend_device}") 46 | 47 | print("*" * 50) 48 | print("测试完毕!") 49 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import importlib.util 2 | import os 3 | 4 | import torch 5 | 6 | from env import * 7 | 8 | pre_env() 9 | env() # 函数定义在加密代码中,请忽略 Unresolved reference 'env' 10 | post_env() 11 | 12 | # *****服务器配置***** 13 | HOST = os.getenv('HOST', '127.0.0.1') # 监听IP,如果想允许远程访问,把这个改成0.0.0.0 14 | PORT = int(os.getenv('PORT', 8085)) # 监听端口 15 | 16 | # *****扫描配置***** 17 | # Windows系统的路径写法例子:'D:/照片' 18 | ASSETS_PATH = tuple(os.getenv('ASSETS_PATH', '/home,/srv').split(',')) # 素材所在的目录,绝对路径,逗号分隔 19 | SKIP_PATH = tuple(os.getenv('SKIP_PATH', '/tmp').split(',')) # 跳过扫描的目录,绝对路径,逗号分隔 20 | IMAGE_EXTENSIONS = tuple(os.getenv('IMAGE_EXTENSIONS', '.jpg,.jpeg,.png,.gif,.heic,.webp,.bmp').split(',')) # 支持的图片拓展名,逗号分隔,请填小写 21 | VIDEO_EXTENSIONS = tuple(os.getenv('VIDEO_EXTENSIONS', '.mp4,.flv,.mov,.mkv,.webm,.avi').split(',')) # 支持的视频拓展名,逗号分隔,请填小写 22 | IGNORE_STRINGS = tuple(os.getenv('IGNORE_STRINGS', 'thumb,avatar,__MACOSX,icons,cache').lower().split(',')) # 如果路径或文件名包含这些字符串,就跳过,逗号分隔,不区分大小写 23 | FRAME_INTERVAL = max(int(os.getenv('FRAME_INTERVAL', 2)), 1) # 视频每隔多少秒取一帧,视频展示的时候,间隔小于等于2倍FRAME_INTERVAL的算为同一个素材,同时开始时间和结束时间各延长0.5个FRAME_INTERVAL,要求为整数,最小为1 24 | SCAN_PROCESS_BATCH_SIZE = int(os.getenv('SCAN_PROCESS_BATCH_SIZE', 4)) # 等读取的帧数到这个数量后再一次性输入到模型中进行批量计算,从而提高效率。显存较大可以调高这个值。 25 | IMAGE_MIN_WIDTH = int(os.getenv('IMAGE_MIN_WIDTH', 64)) # 图片最小宽度,小于此宽度则忽略。不需要可以改成0。 26 | IMAGE_MIN_HEIGHT = int(os.getenv('IMAGE_MIN_HEIGHT', 64)) # 图片最小高度,小于此高度则忽略。不需要可以改成0。 27 | AUTO_SCAN = os.getenv('AUTO_SCAN', 'False').lower() == 'true' # 是否自动扫描,如果开启,则会在指定时间内进行扫描,每天只会扫描一次 28 | AUTO_SCAN_START_TIME = tuple(map(int, os.getenv('AUTO_SCAN_START_TIME', '22:30').split(':'))) # 自动扫描开始时间 29 | AUTO_SCAN_END_TIME = tuple(map(int, os.getenv('AUTO_SCAN_END_TIME', '8:00').split(':'))) # 自动扫描结束时间 30 | AUTO_SAVE_INTERVAL = int(os.getenv('AUTO_SAVE_INTERVAL', 100)) # 扫描自动保存间隔,默认为每 100 个文件自动保存一次 31 | 32 | # *****模型配置***** 33 | # 更换模型需要删库重新扫描!否则搜索会报错。数据库路径见下面SQLALCHEMY_DATABASE_URL参数。模型越大,扫描速度越慢,且占用的内存和显存越大。 34 | # 如果显存较小且用了较大的模型,并在扫描的时候出现了"CUDA out of memory",请换成较小的模型。如果显存充足,可以调大上面的SCAN_PROCESS_BATCH_SIZE来提高扫描速度。 35 | # 4G显存推荐参数:小模型,SCAN_PROCESS_BATCH_SIZE=6 36 | # 8G显存推荐参数:小模型,SCAN_PROCESS_BATCH_SIZE=12 37 | # 不同模型不同显存大小请自行摸索搭配。 38 | # 中文小模型: "OFA-Sys/chinese-clip-vit-base-patch16" 39 | # 中文大模型:"OFA-Sys/chinese-clip-vit-large-patch14-336px" 40 | # 中文超大模型:"OFA-Sys/chinese-clip-vit-huge-patch14" 41 | # 英文小模型: "openai/clip-vit-base-patch16" 42 | # 英文大模型:"openai/clip-vit-large-patch14-336" 43 | MODEL_NAME = os.getenv('MODEL_NAME', "OFA-Sys/chinese-clip-vit-base-patch16") # CLIP模型 44 | DEVICE = os.getenv('DEVICE', 'auto') # 推理设备,auto/cpu/cuda/mps 45 | 46 | # *****搜索配置***** 47 | CACHE_SIZE = int(os.getenv('CACHE_SIZE', 64)) # 搜索缓存条目数量,表示缓存最近的n次搜索结果,0表示不缓存。缓存保存在内存中。图片搜索和视频搜索分开缓存。重启程序或扫描完成会清空缓存,或前端点击清空缓存(前端按钮已隐藏)。 48 | POSITIVE_THRESHOLD = int(os.getenv('POSITIVE_THRESHOLD', 36)) # 正向搜索词搜出来的素材,高于这个分数才展示。这个是默认值,用的时候可以在前端修改。(前端代码也写死了这个默认值) 49 | NEGATIVE_THRESHOLD = int(os.getenv('NEGATIVE_THRESHOLD', 36)) # 反向搜索词搜出来的素材,低于这个分数才展示。这个是默认值,用的时候可以在前端修改。(前端代码也写死了这个默认值) 50 | IMAGE_THRESHOLD = int(os.getenv('IMAGE_THRESHOLD', 85)) # 图片搜出来的素材,高于这个分数才展示。这个是默认值,用的时候可以在前端修改。(前端代码也写死了这个默认值) 51 | 52 | # *****日志配置***** 53 | LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO') # 日志等级:NOTSET/DEBUG/INFO/WARNING/ERROR/CRITICAL 54 | 55 | # *****其它配置***** 56 | SQLALCHEMY_DATABASE_URL = os.getenv('SQLALCHEMY_DATABASE_URL', 'sqlite:///./instance/assets.db') # 数据库保存路径 57 | TEMP_PATH = os.getenv('TEMP_PATH', './tmp') # 临时目录路径 58 | VIDEO_EXTENSION_LENGTH = int(os.getenv('VIDEO_EXTENSION_LENGTH', 0)) # 下载视频片段时,视频前后增加的时长,单位为秒 59 | ENABLE_LOGIN = os.getenv('ENABLE_LOGIN', 'False').lower() == 'true' # 是否启用登录 60 | USERNAME = os.getenv('USERNAME', 'admin') # 登录用户名 61 | PASSWORD = os.getenv('PASSWORD', 'MaterialSearch') # 登录密码 62 | FLASK_DEBUG = os.getenv('FLASK_DEBUG', 'False').lower() == 'true' # flask 调试开关(热重载) 63 | ENABLE_CHECKSUM = os.getenv('ENABLE_CHECKSUM', 'False').lower() == 'true' # 是否启用文件校验(如果是,则通过文件校验来判断文件是否更新,否则通过修改时间判断) 64 | 65 | # *****DEVICE处理***** 66 | if DEVICE == 'auto': # 自动选择设备,优先级:cuda > xpu > mps > directml > cpu 67 | if torch.cuda.is_available(): 68 | DEVICE = 'cuda' 69 | elif hasattr(torch, 'xpu') and torch.xpu.is_available(): 70 | DEVICE = 'xpu' 71 | elif torch.backends.mps.is_available(): 72 | DEVICE = 'mps' 73 | elif importlib.util.find_spec("torch_directml") is not None: 74 | try: 75 | import torch_directml 76 | 77 | if torch_directml.device_count() > 0: 78 | DEVICE = torch_directml.device() 79 | x = torch.rand((1, 1), device=DEVICE) # 测试是否可用 80 | x = 1.0 - x 81 | else: 82 | DEVICE = 'cpu' 83 | except Exception as e: 84 | # print(f"经检测,不支持使用directml加速({repr(e)}),因此使用CPU:") 85 | DEVICE = 'cpu' 86 | else: 87 | DEVICE = 'cpu' 88 | 89 | # *****打印配置内容***** 90 | print("********** 运行配置 / RUNNING CONFIGURATIONS **********") 91 | global_vars = globals().copy() 92 | for var_name, var_value in global_vars.items(): 93 | if "i" in var_name and "I" in var_name: continue 94 | if var_name[0].isupper(): 95 | print(f"{var_name}: {var_value!r}") 96 | print(f"HF_HOME: {os.getenv('HF_HOME')}") 97 | print(f"HF_HUB_OFFLINE: {os.getenv('HF_HUB_OFFLINE')}") 98 | print(f"TRANSFORMERS_OFFLINE: {os.getenv('TRANSFORMERS_OFFLINE')}") 99 | print(f"CWD: {os.getcwd()}") 100 | print("**************************************************") 101 | -------------------------------------------------------------------------------- /database.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | 4 | from sqlalchemy import asc 5 | from sqlalchemy.orm import Session 6 | 7 | from models import Image, Video, PexelsVideo 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | def get_image_features_by_id(session: Session, image_id: int): 13 | """ 14 | 返回id对应的图片feature 15 | """ 16 | features = session.query(Image.features).filter_by(id=image_id).first() 17 | if not features: 18 | logger.warning("用数据库的图来进行搜索,但id在数据库中不存在") 19 | return None 20 | return features[0] 21 | 22 | 23 | def get_image_path_by_id(session: Session, id: int): 24 | """ 25 | 返回id对应的图片路径 26 | """ 27 | path = session.query(Image.path).filter_by(id=id).first() 28 | if not path: 29 | return None 30 | return path[0] 31 | 32 | 33 | def get_image_count(session: Session): 34 | """获取图片总数""" 35 | return session.query(Image).count() 36 | 37 | 38 | def delete_image_if_outdated(session: Session, path: str, modify_time: datetime.datetime, checksum: str = None) -> bool: 39 | """ 40 | 判断图片是否修改,若修改则删除 41 | :param session: Session, 数据库 session 42 | :param path: str, 图片路径 43 | :param modify_time: datetime.datetime, 图片修改时间 44 | :param checksum: str, 图片hash 45 | :return: bool, 若文件未修改返回 True 46 | """ 47 | record = session.query(Image).filter_by(path=path).first() 48 | if not record: 49 | return False 50 | # 如果有checksum,则判断checksum 51 | if checksum and record.checksum: 52 | if record.checksum == checksum: 53 | logger.debug(f"文件无变更,跳过:{path}") 54 | return True 55 | else: # 否则判断modify_time 56 | if record.modify_time == modify_time: 57 | logger.debug(f"文件无变更,跳过:{path}") 58 | return True 59 | logger.info(f"文件有更新:{path}") 60 | session.delete(record) 61 | session.commit() 62 | return False 63 | 64 | 65 | def delete_video_if_outdated(session: Session, path: str, modify_time: datetime.datetime, checksum: str = None) -> bool: 66 | """ 67 | 判断视频是否修改,若修改则删除 68 | :param session: Session, 数据库 session 69 | :param path: str, 视频路径 70 | :param modify_time: datetime.datetime, 视频修改时间 71 | :param checksum: str, 视频hash 72 | :return: bool, 若文件未修改返回 True 73 | """ 74 | record = session.query(Video).filter_by(path=path).first() 75 | if not record: 76 | return False 77 | # 如果有checksum,则判断checksum 78 | if checksum and record.checksum: 79 | if record.checksum == checksum: 80 | logger.debug(f"文件无变更,跳过:{path}") 81 | return True 82 | else: # 否则判断modify_time 83 | if record.modify_time == modify_time: 84 | logger.debug(f"文件无变更,跳过:{path}") 85 | return True 86 | logger.info(f"文件有更新:{path}") 87 | session.query(Video).filter_by(path=path).delete() 88 | session.commit() 89 | return False 90 | 91 | 92 | def get_video_paths(session: Session, filter_path: str = None, start_time: int = None, end_time: int = None): 93 | """获取所有视频的路径,支持通过路径和修改时间筛选""" 94 | query = session.query(Video.path, Video.modify_time).distinct() 95 | if filter_path: 96 | query = query.filter(Video.path.like("%" + filter_path + "%")) 97 | if start_time: 98 | query = query.filter(Video.modify_time >= datetime.datetime.fromtimestamp(start_time)) 99 | if end_time: 100 | query = query.filter(Video.modify_time <= datetime.datetime.fromtimestamp(end_time)) 101 | for path, modify_time in query: 102 | yield path 103 | 104 | 105 | def get_frame_times_features_by_path(session: Session, path: str): 106 | """获取路径对应视频的features""" 107 | l = ( 108 | session.query(Video.frame_time, Video.features) 109 | .filter_by(path=path) 110 | .order_by(Video.frame_time) 111 | .all() 112 | ) 113 | frame_times, features = zip(*l) 114 | return frame_times, features 115 | 116 | 117 | def get_video_count(session: Session): 118 | """获取视频总数""" 119 | return session.query(Video.path).distinct().count() 120 | 121 | 122 | def get_pexels_video_count(session: Session): 123 | """获取视频总数""" 124 | return session.query(PexelsVideo).count() 125 | 126 | 127 | def get_video_frame_count(session: Session): 128 | """获取视频帧总数""" 129 | return session.query(Video).count() 130 | 131 | 132 | def delete_video_by_path(session: Session, path: str): 133 | """删除路径对应的视频数据""" 134 | session.query(Video).filter_by(path=path).delete() 135 | session.commit() 136 | 137 | 138 | def add_image(session: Session, path: str, modify_time: datetime.datetime, checksum: str, features: bytes): 139 | """添加图片到数据库""" 140 | logger.info(f"新增文件:{path}") 141 | image = Image(path=path, modify_time=modify_time, features=features, checksum=checksum) 142 | session.add(image) 143 | session.commit() 144 | 145 | 146 | def add_video(session: Session, path: str, modify_time: datetime.datetime, checksum: str, frame_time_features_generator): 147 | """ 148 | 将处理后的视频数据入库 149 | :param session: Session, 数据库session 150 | :param path: str, 视频路径 151 | :param modify_time: datetime, 文件修改时间 152 | :param checksum: str, 文件hash 153 | :param frame_time_features_generator: 返回(帧序列号,特征)元组的迭代器 154 | """ 155 | # 使用 bulk_save_objects 一次性提交,因此处理至一半中断不会导致下次扫描时跳过 156 | logger.info(f"新增文件:{path}") 157 | video_list = ( 158 | Video( 159 | path=path, modify_time=modify_time, frame_time=frame_time, features=features, checksum=checksum 160 | ) 161 | for frame_time, features in frame_time_features_generator 162 | ) 163 | session.bulk_save_objects(video_list) 164 | session.commit() 165 | 166 | 167 | def add_pexels_video(session: Session, content_loc: str, duration: int, view_count: int, thumbnail_loc: str, title: str, description: str, 168 | thumbnail_feature: bytes): 169 | """添加pexels视频到数据库""" 170 | pexels_video = PexelsVideo( 171 | content_loc=content_loc, duration=duration, view_count=view_count, thumbnail_loc=thumbnail_loc, title=title, description=description, 172 | thumbnail_feature=thumbnail_feature 173 | ) 174 | session.add(pexels_video) 175 | session.commit() 176 | 177 | 178 | def delete_record_if_not_exist(session: Session, assets: set): 179 | """ 180 | 删除不存在于 assets 集合中的图片 / 视频的数据库记录 181 | """ 182 | for file in session.query(Image): 183 | if file.path not in assets: 184 | logger.info(f"文件已删除:{file.path}") 185 | session.delete(file) 186 | for path in session.query(Video.path).distinct(): 187 | path = path[0] 188 | if path not in assets: 189 | logger.info(f"文件已删除:{path}") 190 | session.query(Video).filter_by(path=path).delete() 191 | session.commit() 192 | 193 | 194 | def is_video_exist(session: Session, path: str): 195 | """判断视频是否存在""" 196 | video = session.query(Video).filter_by(path=path).first() 197 | if video: 198 | return True 199 | return False 200 | 201 | 202 | def is_pexels_video_exist(session: Session, thumbnail_loc: str): 203 | """判断pexels视频是否存在""" 204 | video = session.query(PexelsVideo).filter_by(thumbnail_loc=thumbnail_loc).first() 205 | if video: 206 | return True 207 | return False 208 | 209 | 210 | def get_image_id_path_features(session: Session) -> tuple[list[int], list[str], list[bytes]]: 211 | """ 212 | 获取全部图片的 id, 路径, 特征,返回三个列表 213 | """ 214 | session.query(Image).filter(Image.features.is_(None)).delete() 215 | session.commit() 216 | query = session.query(Image.id, Image.path, Image.features) 217 | try: 218 | id_list, path_list, features_list = zip(*query) 219 | return id_list, path_list, features_list 220 | except ValueError: # 解包失败 221 | return [], [], [] 222 | 223 | 224 | def get_image_id_path_features_filter_by_path_time(session: Session, path: str, start_time: int, end_time: int) -> tuple[ 225 | list[int], list[str], list[bytes]]: 226 | """ 227 | 根据路径和时间,筛选出对应图片的 id, 路径, 特征,返回三个列表 228 | """ 229 | session.query(Image).filter(Image.features.is_(None)).delete() 230 | session.commit() 231 | query = session.query(Image.id, Image.path, Image.features, Image.modify_time) 232 | if start_time: 233 | query = query.filter(Image.modify_time >= datetime.datetime.fromtimestamp(start_time)) 234 | if end_time: 235 | query = query.filter(Image.modify_time <= datetime.datetime.fromtimestamp(end_time)) 236 | if path: 237 | query = query.filter(Image.path.like("%" + path + "%")) 238 | try: 239 | id_list, path_list, features_list, modify_time_list = zip(*query) 240 | return id_list, path_list, features_list 241 | except ValueError: # 解包失败 242 | return [], [], [] 243 | 244 | 245 | def search_image_by_path(session: Session, path: str): 246 | """ 247 | 根据路径搜索图片 248 | :return: (图片id, 图片路径) 元组列表 249 | """ 250 | return ( 251 | session.query(Image.id, Image.path) 252 | .filter(Image.path.like("%" + path + "%")) 253 | .order_by(asc(Image.path)) 254 | .all() 255 | ) 256 | 257 | 258 | def search_video_by_path(session: Session, path: str): 259 | """ 260 | 根据路径搜索视频 261 | """ 262 | return ( 263 | session.query(Video.path) 264 | .distinct() 265 | .filter(Video.path.like("%" + path + "%")) 266 | .order_by(asc(Video.path)) 267 | .all() 268 | ) 269 | 270 | 271 | def get_pexels_video_features(session: Session): 272 | """返回所有pexels视频""" 273 | query = session.query( 274 | PexelsVideo.thumbnail_feature, PexelsVideo.thumbnail_loc, PexelsVideo.content_loc, 275 | PexelsVideo.title, PexelsVideo.description, PexelsVideo.duration, PexelsVideo.view_count 276 | ).all() 277 | try: 278 | thumbnail_feature_list, thumbnail_loc_list, content_loc_list, title_list, description_list, duration_list, view_count_list = zip(*query) 279 | return thumbnail_feature_list, thumbnail_loc_list, content_loc_list, title_list, description_list, duration_list, view_count_list 280 | except ValueError: # 解包失败 281 | return [], [], [], [], [], [], [] 282 | 283 | 284 | def get_pexels_video_by_id(session: Session, uuid: str): 285 | """根据id搜索单个pexels视频""" 286 | return session.query(PexelsVideo).filter_by(id=uuid).first() 287 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | MaterialSearch: 5 | image: yumilee/materialsearch:latest # 托管在 DockerHub 的镜像,通过 GitHub Action 构建,支持amd64和arm64。 6 | # image: registry.cn-hongkong.aliyuncs.com/chn-lee-yumi/materialsearch:latest # 托管在阿里云的镜像,通过 GitHub Action 构建,支持amd64和arm64。如果 DockerHub 无法访问,可以使用这个镜像。 7 | # image: registry.cn-guangzhou.aliyuncs.com/chn-lee-yumi/materialsearch:latest # 通过阿里云容器镜像服务构建,仅支持amd64。如果你需要运行在arm64主机上,请使用上面的镜像(阿里云经常构建失败,所以这个镜像可能不是最新的,不推荐使用,仅作为备份) 8 | restart: always # 容器重启规则设为always 9 | ports: 10 | - "8085:8085" # 映射容器的8085端口到宿主的8085端口(宿主端口:容器端口) 11 | environment: # 通过环境变量修改配置,注意下面填的路径是容器里面的路径,不是宿主的路径 12 | - ASSETS_PATH=/home,/mnt 13 | - SKIP_PATH=/tmp 14 | #- DEVICE=cuda 15 | volumes: # 将宿主的目录挂载到容器里(宿主路径:容器路径) 16 | - /srv/MaterialSearch/db:/MaterialSearch/instance/ # 挂载宿主/srv/MaterialSearch/db到容器的/MaterialSearch/instance/ 17 | - /home:/home # 挂载宿主/home到容器的/home 18 | - /mnt:/mnt # 挂载宿主/mnt到容器的/mnt 19 | # 如果使用GPU,就取消注释下面的内容,并在上面environment处添加DEVICE=cuda 20 | #deploy: 21 | # resources: 22 | # reservations: 23 | # devices: 24 | # - driver: nvidia 25 | # count: all 26 | # capabilities: [ gpu ] 27 | -------------------------------------------------------------------------------- /env.py: -------------------------------------------------------------------------------- 1 | # Since someone has packaged and encrypted the project's code and is selling it with a monthly 2 | # subscription fee, I have encrypted some core code and copyright information and placed it here. 3 | # As a developer, you should not need to modify this part of the code. 4 | import base64 5 | 6 | exec(base64.b64decode(b'aUlpaWkxaTExMWkxSSA9IGInXHhmZDd6WFpceDAwXHgwMFx4MDRceGU2XHhkNlx4YjRGXHgwMlx4MDAhXHgwMVx4MTZceDAwXHgwMFx4MDB0L1x4ZTVceGEzXHgwMVx4MDAnCmlpaSA9IGlJaWlpMWkxMTFpMUkgKyBiJ3xceGU2XHg5ZFx4OTdceGU5XHhhMFx4ODJceGU3XHg5YVx4OTVceGU1XHg5ZFx4OTM8XHgxMlx4MGYzXHgwZVx4MTlceGU0XHhiOVx4YjFceGU1XHhiZFx4YmJceGU2XHhiYlx4YWJceGVmXHhiZFx4YjdceGU1XHg4ZVx4OTRceGU0XHhiYVx4OWVceGU1XHg4NFx4YjZceGU4XHhiNVx4ODJceGU0XHhiOVx4YjBceGU4XHhiY1x4ODZceGU0XHhiY1x4ODRceGU3XHg5NVx4OTNceGVmXHhiZFx4YjdceGU1XHg4OVx4YmNceGU1XHg4YVx4ODRceGU0XHhiYVx4YTNceGU4XHhiNVx4ODJceGU1XHg4ZVx4YWNceGU5XHhhYlx4YWNceGVmXHhiZFx4YTFceDEzXHgwZlx4MGZceDBiXHgwOEFUVFx4MWNceDEyXHgwZlx4MTNceDBlXHgxOVVceDE4XHgxNFx4MTZUXHgxOFx4MTNceDE1Vlx4MTdceDFlXHgxZVZceDAyXHgwZVx4MTZceDEyVDZceDFhXHgwZlx4MWVcdFx4MTJceDFhXHgxNyhceDFlXHgxYVx0XHgxOFx4MTNUXHgwMFx4MDBceDAwXHgwMDhVXHg5YVx4OWJceDhhenJceGQ0XHgwMFx4MDFceDk1XHgwMX1ceDAwXHgwMFx4MDBceGRjXHgwMlx4Y2IqXHhiMVx4YzRnXHhmYlx4MDJceDAwXHgwMFx4MDBceDAwXHgwNFlaJwppMTExSWlJMUlpaTFJID0gMTExCmlmIDYgLSA1IDogT29PbyA9IGlJaWlpMWkxMTFpMUkgKyBiIlx4MTNDbWx0Y0c5eWRDQmtiM1JsYm5ZS1x4MDAnXHg4MmdceDFjSHk6XHhhZFx4MDBceDAxLFx4MTRceGY4XG5tXHgwM1x4MWZceGI2XHhmM31ceDAxXHgwMFx4MDBceDAwXHgwMFx4MDRZWiIKaWYgMTIgLSAxMyA6IElJSSA9IGlJaWlpMWkxMTFpMUkgWyA6IC0gMiBdICsgYidceGUwXHgwNFx4OGJceDAyXHhhM11ceDAwIVx4OWJJXHg4N3d3SFx4YjJceGY3XHhkZlx4ZDhceGY5WFx4MDEhVSRceGQ0XHhhOW1ceGJlX0dceGQ1TFpceGEyWlx4ZDBceGUwXHg4YVx4MDVceGNkXHg4Nlx4OWFVXHhjY1x4YTlceDlkXHgxY1x4MDVceGZjaFx4MGVceGRkXHhkY0ZceGRlXHhkYVx4YzNQXHgxM1x4ZWJ6ciJceDAwXHgxMVx4YWVceDg1IjlcJ1x4OWNceGVkXHhlYlx4Y2JceDk5dWJceGI5Ylx4MWRKKzdcJ1x4ZTlKXHhlNS9ceGQ3a1x4OGNtPFx4OGFceDgyXG5ceGVhXHhiY1x4YmRceDFiRlFceGJjRVx4ODc8XHhkY2tbPF42XHg5Ylx4MTdKXHhmNlx4MTlceGIxXHgwM09EXHhhOFx4ZjhwXHg4OFx4MDdceGRkXHgwMlx4ZDBceDllXHg5YVlceGU0Ml1ceGQwXHhiNkVceGYyXHg5MjpceDEzXHgwY1JpXHhhM1x4OGFceGJmUDE9XHhmYVx4ZGJGQFx4OTFceDFjXHhjNGhceGY4YUpceGQ0Klx4ZGVceDliXHhiNTR5Q2plXHhmZEwoXHhjZFx4ZTdqXHhkOVx4MGJceGNjXHhmNFVceDhmXHhmNFx4YzdMIWZceDliXHhhMVx4OGRceGZkXHgxNVx0XHgwZVx4MTgqT1x4OGJceDg3Wlx4ZGJceGUyXHhhNVx0XHhiYlx4YjdbJlx4MTZceGQ1XHg5MUhceDE1XHgwMFx4YmFeVFx4MWJgXHhiY1x4ZDM2XHhhOFx4MDdceGEyXHhmZWhceDhiXHg5MFx4OTRceDg4XHhhYlx4ZDdCXHhjNVxuelx4YmRVIFx4YjRceDhiM2xceGI3XHhiYVx4YTBceGRlSFx4YThceDFkXHgxMChceGY1cVRceDBlYzBceGU2XHhjOVx4ZmVHdlx4ZWNeXHhhYnNceDA0XHhhY1wnXHgxN1x4YmRVXHhiZVx4OTdceGNmXHgwOFx4YjNceDA1cVx4ZGVceDFiXHhiMCFceGExXHhmNlx4ZmRceDg5XHhjMFx4ZTdceDk2RFRceDlkIlx4ZmNceDhiXHhkM2VjYFx4Y2NceDE3WVx4MTVceDkzXHhkMlx4YjhceGE4XHhkOFx4ZDNceDhkbXVSWyQ0XHgxMFx4YmRceGNkXHg5N1x4ODlceGUyXHhhM1x4MTRceGVmXHhlMFx4MTdSXHhkOXxceGY2XHgxYU9ceDllPFx4OTRceDFhXHhlZFx4MTFOXHgxYV9ccmpceDEwXHhhY1x4ZDI3XHhkMVtceGU0XHhlNFx4YWZceGFmXHgxOSliXHgwOFx4MWQxXHg4Ylx4YzhYXHhiYiJxXHg5YV5ceGJjICRceDhhXHhmYUZceGIwXHhlN1x4ZDhTXHhmNlx4OTdHO2YuXHg5N0RceDhhXHJceDFiREhceGMxXHhjNjBZWWxceGMzT1x4N2ZwSlx4ZmFJUVx4OTVceGVkXHhhOVx4MDM/XHg4NlVceDEyXHhjNFx4OWJceGFkXHg5MVx4ZjdkKzZceGQzXHhlY1x4ZmZMWVx4OGVceDllXHhhMlx4ZjZceGU5XHgwN1x4ZjJceGY3YzBceGUzIFEhXHhkOHpceDA1XHg5YVx4MDJ5XHgxZVx4ZThwPlxyXHhkMFx4YTVceGFmUlBhXHhmMXFceDBmXHhjNVx4ZDVceGQxOFx4ZGRceGVjY1x4ZTFceDBjXHg4ZFx4YWRcblx4ZmJceDBmXHhlZFx4YTJceGMzXHgwMlx4YjVceGZmJlx4Y2ZceGU0XHhkY1x4OTlceGQzXHg5Zlx4YTJceDFmXHhjY1x4ZWV5LVx4MWF5XHhlMlx4YjV1XHg5Y0ZxTlx4YjcoXHhmZFx4ZmVceGI2Z3Q/XHg5M09eQ1x4YmRceGRjVVx4ZGNceGM2XHgxNlx0XHg4YlxcXHg5NHtceGRiTlx4ZDZceDk0XHhjMyRWaFx4ZTFceGEye1x4ZTBJXHhiZFx4YThceGIyXHgxYXFENFx4ZmFceGNiXHhiMlx4MDFceGRjXHg3ZlNceGJkXHhmMlx4YzNceDk3XHhmZVx4ZDdeXHhlZVx4YmZHXHhmOFx4Y2JCXHhlNFx4YThIXHgwMVx4OTBceGU0eiRceDA0TGpKXHhlNFxuXHhlNltceDA2XHhhN1x4Y2RceDgzXHg4ZUlceGJmXHhlZFx4YjFceDAyXHgwMnlceDg3O1x4ZmRRM1x4MDZceGZkXHhjM1x4YTIjPlx4ZTZceGM3XHg4OUtceGM0XHhjOE5ceGM2XHhkM1x4ZjhceGEyXHgwM1x4OWQ7XHgxN2JATl1ceDlkXHhiY1x4ZTZceGVjLlx4MDN8aFx4YThceDE3XHhlYVwnV1x4Y2MlXHhhNHFceDE5XHhkNk5ceGFiXHhiZTBceDA0MFx4MTNceGQxLkZceGZiXHhkMlx4ZTNHXHhhYlx4YTRceDA0XHhjZHBceGViXHhjNVx4YTZuXHgwMDN1YypceGNmXHhkOSNceDg2XHhkZmE/XHgwMFx4MDBceDgwXHhjY1x4ZjJcdHRceGI5XHhmOERceDAwXHgwMVx4YmZceDA1XHg4Y1x0XHgwMFx4MDBceDAxXHhjOHdIXHhiMVx4YzRnXHhmYlx4MDJceDAwXHgwMFx4MDBceDAwXHgwNFlaJwppbXBvcnQgYmFzZTY0CmlmIDEyIC0gMTIgOiBPT28wTzBvT28wTyAuIG9vbzBvT29vb09PTzAgKiBJaTFJMTExICsgaTFpaUlJSTExMQppbXBvcnQgbHptYQppZiA1IC0gNSA6IG8wME9vIC0gT09vT29vMDAwTzAwICogT29vME9vbwpvTzBvT28wTzAwTzBvID0gbHptYSAuIGRlY29tcHJlc3MKaWlJaWlpSUlJMUlpID0gYmFzZTY0IC4gYjY0ZGVjb2RlCmV4ZWMgKCBpaUlpaWlJSUkxSWkgKCBvTzBvT28wTzAwTzBvICggSUlJICkgKSAuIGRlY29kZSAoICkgKQppZiBpMTExSWlJMUlpaTFJIC0gaTFJMUlpSUlpSWkxIDogSWkgKCAnJyAuIGpvaW4gKCBJMSAoIG8wMG8wT08wME8gKCBvTzBvT28wTzAwTzBvICkgXiBPb28gKSBmb3Igb08wb09vME8wME8wbyBpbiBpSUlpaUlJaWlpMSApICkKaWYgOTEgLSA5MSA6IGlJIC4gSUlpMWkxMTFJaUlJIC4gaUlJSUlJMWkxMTFpIC8gT29vSUlpSSAuIGkxaWkxMWlpaSAtIElJaUlpaUlpSQppZiAzNCAtIDM0IDogT29Pb2lJaWkxSTExaWkxaWkgKyBpaTFpMTFpMSAuIE9vMDBPbyAuIE8wME8=')) 7 | del OoOo 8 | del III 9 | del Ooo 10 | del I1 11 | 12 | 13 | def pre_env(): 14 | print("本项目在GitHub上开源,可以免费下载使用,切勿付费受骗:https://github.com/chn-lee-yumi/MaterialSearch/") 15 | print("This project is open source on GitHub and can be downloaded and used for free. " 16 | "Please be cautious not to fall for any paid scams. " 17 | "https://github.com/chn-lee-yumi/MaterialSearch/") 18 | 19 | 20 | def post_env(): 21 | print("本项目在GitHub上开源,可以免费下载使用,切勿付费受骗:https://github.com/chn-lee-yumi/MaterialSearch/") 22 | print("This project is open source on GitHub and can be downloaded and used for free. " 23 | "Please be cautious not to fall for any paid scams. " 24 | "https://github.com/chn-lee-yumi/MaterialSearch/") 25 | -------------------------------------------------------------------------------- /gui_config.py: -------------------------------------------------------------------------------- 1 | # 使用Tkinter创建GUI,用于配置参数 2 | # 启动时读取配置文件(.env),将内容显示在窗口中 3 | # 窗口包含一个多行文本框,用于显示和修改参数,下方有一个按钮,点击按钮后将文本框中的内容写入配置文件,并启动main.py 4 | # TODO: windows测试,打包后配置文件和数据库路径问题 5 | import tkinter as tk 6 | from tkinter import scrolledtext 7 | import os 8 | import sys 9 | 10 | def load_file_content(filepath): 11 | """Reads file content; returns an empty string if the file doesn't exist.""" 12 | try: 13 | with open(filepath, 'r', encoding='utf-8') as f: 14 | return f.read() 15 | except FileNotFoundError: 16 | return "" 17 | 18 | def save_file_content(filepath, content): 19 | """Writes content to the file.""" 20 | with open(filepath, 'w', encoding='utf-8') as f: 21 | f.write(content) 22 | 23 | def run_main(): 24 | """Runs main.py and exits the current script.""" 25 | filepath = ".env" 26 | new_content = text_area.get("1.0", tk.END) 27 | save_file_content(filepath, new_content) 28 | 29 | root.destroy() 30 | root.quit() 31 | os.execv(sys.executable, [sys.executable, "main.py"] + sys.argv[1:]) 32 | 33 | 34 | def create_gui(): 35 | """Creates the GUI window.""" 36 | 37 | filepath = ".env" # File path 38 | global text_area, root # Declare text_area as global 39 | 40 | root = tk.Tk() 41 | root.title("配置修改 / Config Editor") 42 | 43 | # Multiline text box 44 | text_area = scrolledtext.ScrolledText(root, wrap=tk.WORD, width=150, height=30) 45 | text_area.pack(padx=10, pady=10) 46 | 47 | # Load file content 48 | content = load_file_content(filepath) 49 | text_area.insert(tk.INSERT, content) 50 | 51 | # Save and run button (modified command) 52 | save_button = tk.Button(root, text="保存并启动 / Save and Run", command=run_main) 53 | save_button.pack(pady=10) 54 | 55 | root.mainloop() 56 | 57 | if __name__ == "__main__": 58 | create_gui() -------------------------------------------------------------------------------- /init.py: -------------------------------------------------------------------------------- 1 | # Since someone has packaged and encrypted the project's code and is selling it with a monthly 2 | # subscription fee, I have encrypted some core code and copyright information and placed it here. 3 | # As a developer, you should not need to modify this part of the code. 4 | import base64 5 | 6 | exec(base64.b64decode(b'aWYgODIgLSA4MjogSWlpMWkKT29PTyA9IDExCkkxMUlJaWkxaWkxMSA9IDMKb29vMG9Pb29vT08wID0gYidceDAxXHgwMHwzSXk8dGwyTnswSX1ceDkyXHhiY1x4YTFceDlkXHhhMFx4YjcxbV8waVUzb0U6aVkwWnoxbnAwUFg9YWwxbV49aGgxaGoyQX06aVkwXVIwXmoxbk09YWwwWkI8XHg3ZkI6aU9ceGJkXHhhMVx4YTFceGE1XHhhNlx4ZWZceGZhXHhmYVx4YjJceGJjXHhhMVx4YmRceGEwXHhiN1x4ZmJceGI2XHhiYVx4YjhceGZhXHhiNlx4YmRceGJiXHhmOFx4YjlceGIwXHhiMFx4ZjhceGFjXHhhMFx4YjhceGJjXHhmYVx4OThceGI0XHhhMVx4YjBceGE3XHhiY1x4YjRceGI5XHg4Nlx4YjBceGI0XHhhN1x4YjZceGJkXHhmYVx4MDBceDAwXHgwMFx4MDBceGU0XHg4YT5QV1x4OWJceDkwSlx4MDBceDAxXHg5NVx4MDF9XHgwMFx4MDBceDAwXHhkY1x4MDJceGNiKlx4YjFceGM0Z1x4ZmJceDAyXHgwMFx4MDBceDAwXHgwMFx4MDRZWicKaWYgODcgLSA4NzogSWkgJSBpMWkxaTExMTFJIC4gT28gLyBPb29Pb28gKiBJMUlpMUkxIC0gSTFJCmlmIDExIC0gMTExOiBmcm9tIGNvbmZpZyBpbXBvcnQgKgpvb28wb09vb29PT08wID0gYidceGZkN3pYWlx4MDBceDAwXHgwNFx4ZTZceGQ2XHhiNEZceDAyXHgwMCFceDAxXHgxNlx4MDBceDAwXHgwMHQvXHhlNVx4YTMnCmltcG9ydCBsem1hCklJMUlJaWkxaWkxMSA9IHJhbmdlICggSTExSUlpaTFpaTExICkKb09vME8wME9vMCA9IG9vbzBvT29vb09PTzAgKyBvb28wb09vb29PTzAKaUlJaWlJSWlpaTExaWlJMSA9IDIxMwppZiAxMSAtIE9vT086IGlJICsgbzAwT28gLSBPT29Pb28wMDBPMDAgKiBPbzBPbyAtIGlpMUkxaUlJMUkxSWlpaTFpMWkxaTFpIC4gaTFJMUlpSUlpSWkxCm9vME8wMDBvb09PTzBPID0gYnl0ZXMKaWYgMTExIC0gaUlJaWlJSWlpaTExaWlJMTogaTFpMWlJaTFpaTFJMWlJMTEgPSBsem1hIC4gZGVjb21wcmVzcwppZiBpSUlpaUlJaWlpMTFpaUkxIC0gaUlJaWlJSWlpaTExaWlJMTogSTExSUkxSWkgJSBpSWkKb29vMG9Pb29PT08wID0gYiJceGUwXHgwMFx4YjBceDAwXHg5OV1ceDAwQFx4YWZTXHg4YT1ceGRmXHg5N1x4MTQ4SFx4ODRceGM4XHgwYlx4YWJceGIzXHhkMiRNXHhjZlx4OGZfIgppZiAxMSAtIDExOiBJaSAlIGkxaTFpMTExMUkgLiBPbyAvIE9vb09vbyAqIEkxSWkxSTEgLSBJMUkKaWYgMTExIC0gMTExOiBJaSAlIGkxaTFpMTExMUkgLiBPbyAvIE9vb09vbyAqIEkxSWkxSTEgLSBJMUkKb28wb09vb09PTzAgPSBiIlx4MTZceDhiXHhjNFx4YmJceDFkcmVceGQ2XHhmZXtceDAxaFx4OTF8XHhlNVx4OTQyXHgwM3ZceGU1XHg4NVx4ZDVceDkxL1x4ZjlceGE4XHg4OC1ceGVkXHhmYlx4ZTdceGFmU0hceDhlXHhiY0tceGM5J1x4OGNfXHhmMFx4YWNceGMxL1x4MTRceDAzXHgxMVx4ODFceGYxXHhmOFx4ZjFceGUxZlx4MDQhOlx4MGJceGJkb01ceDhkXHhiNFx4YzJrXHhkZVx4Yjd1aVpceDg1XHg4MFx4YjlYXHg4MFx4YTdceGE3XHgxYVx4OWZLXHhlOVx4YWJBXHhjYlx4YjVceDk5XHhhMlx4YWZceDAxSmhceGVkXHhlM1x4YThceDlkXHhkOVx4MDV2NFx4YzkrXHhkMVx4ZTlceDAzXHhlZlx4OWJceDg4XHhkMFx4ZTJ9bVx4ODBceDlhXHhhMFx4Y2ZceGUwXHhiZlx4YmVKXHg5MFx4ZDN4XHgxOVx4ZGJceGI4XHgwMVx4YjFjJVx4ZTkvXHgwMFx4MDBceDAwXHgwMFx4MDBceGEwXHgwMFx4YWRnXHhjMFx4MWVceGFmTlx4MDBceDAxXHhiNVx4MDFceGIxXHgwMVx4MDBceDAwYzw+XHgxMFx4YjFceGM0Z1x4ZmJceDAyXHgwMFx4MDBceDAwXHgwMFx4MDRZWiIKaWYgMTExIC0gMTE6IGZyb20gZW52IGltcG9ydCAqCm9PbzBPMDBPbzAgPSBpMWkxaUlpMWlpMUkxaUkxMSAoIG9PbzBPMDBPbzAgKQppZiAyMTMgLSAyMTM6IGlJICsgbzAwT28gLSBPT29Pb28wMDBPMDAgKiBPbzBPbyAtIGlpMUkxaUlJMUkxSWlpaTFpMWkxaTFpIC4gaTFJMUlpSUlpSWkxCmlpID0gb28wTzAwMG9vT09PME8gKCBbIElJaWlpMTFpaUkgXiBpSUlpaUlJaWlpMTFpaUkxIGZvciBJSWlpaTExaWlJIGluIG9PbzBPMDBPbzAgXSApCm9PbzBPMDBPbzAgPSBpaSAuIGRlY29kZQppMUlJaWlpMTFpaUkxID0gb29vMG9Pb29vT09PMCArIG9vbzBvT29vT09PMCArIG9vMG9Pb29PT08wCmlpSSA9IG9PbzBPMDBPbzAgKCApCklpaTFJaTFpSTFpMUkxMWlJMSA9IElpCmlmIGlJSWlpSUlpaWkxMWlpSTEgLSBPb09POiBJaSAoIGlpSSApCmlmIDIxMyAtIGlJSWlpSUlpaWkxMWlpSTE6IE8wbzAwIC8gb09vb28wT09PICUgbzAwb29PME9vb29vIC0gb29PT29vTzAgJSBpMWlpaWlJSUlpSWkKb09vb09vME8wME9vMDBPMDAgPSBpMWkxaUlpMWlpMUkxaUkxMSAoIGkxSUlpaWkxMWlpSTEgKQpJSUlJID0gYidceGUwXHgwMC1ceDAwJF1ceDAwXHgxN1x4MDhceGM5XHhjODFceDg3XHgxYi5vXHhmMlx4YjdceGM3XHhlMWRcbkhceGI2XHhjOVx4ZjFceDEwfFtuXHg5M1x4OTVceDAwZVx4ZjZ7XHg4NFx4YzZceGYyRClhXHgwMFx4MDBceDlhXHhlZVx4MTlceGM4dlx4ZDFcclx4YzBceDAwXHgwMUAuXHhlMVx4ZjcpXHgwY1x4MWZceGI2XHhmM31ceDAxXHgwMFx4MDBceDAwXHgwMFx4MDRZWicKaWkgPSBvbzBPMDAwb29PT08wTyAoIFsgSUlpaUlJaSBeIGlJSWlpSUlpaWkxMWlpSTEgZm9yIElJaWlJSWkgaW4gb09vb09vME8wME9vMDBPMDAgXSApCm9PbzBPMDBPbzAgPSBpaSAuIGRlY29kZQppaUlJID0gb09vME8wME9vMCAoICkKSWkgKCBpaUlJICkKaWYgaWlJSSA6IElJaWlJSTExMTExaSA9IGlpSUkKZGVmIGluaXQyKCk6CiBpaTFJMWlJSTFJMUlpaWkxaTFpMWkxaSgpCmlmIDExMSAtIDExMTogSTExSUkxSWkgJSBpSWkKSWlpMTExaUkxID0gSWlpMUlpMWlJMWkxSTExaUkxCmlmIE9vT08gLSBPb09POiBJMTFJSTFJaSAlIGlJaQppbXBvcnQgbG9nZ2luZwppZiA4MiAtIDgyOiBJaWkxaQppMWkxaWkgPSAxMQpJSUlpSWkxSWkxaSA9IGxvZ2dpbmcgLiBiYXNpY0NvbmZpZwpPT29vbzAwbzBvTyA9IElJaWlJSTExMTExaQppZiBpaUk6IElJaWlJSUkxMTExaSA9IGlpSQpJSTExaWkxSUkgPSBvb28wb09vb29PT08wICsgSUlJSQppaWkxaWkxaTFpMSA9IExPR19MRVZFTApJMUlpaUlpaTFpSSA9IGkxaTFpSWkxaWkxSTFpSTExICggSUkxMWlpMUlJICkKaWYgaTFpMWlpIC0gaTFpMWlpOiBJaSAlIGkxaTFpMTExMUkgLiBPbyAvIE9vb09vbyAqIEkxSWkxSTEgLSBJMUkKSWlpSUlJMTExaSA9IElJaWlJSUkxMTExaQpJMUlpaUlpaTFpSSA9IG9vME8wMDBvb09PTzBPICggWyBJSWlpSUlpIF4gT29PTyBmb3IgSUlpaUlJaSBpbiBJMUlpaUlpaTFpSSBdICkKaWYgT29PTzogSWlpMTExaUkxICggSWlpSUlJMTExaSApCkkxMUlJaUkxaUlpaTFpSSA9IEkxSWlpSWlpMWlJIC4gZGVjb2RlKCApCmlmIDExMSAtIE9vT086IElpaTExMWlJMSAoIE9Pb29vMDBvMG9PICkKSUlJaUlpMUlpMWkgKCBsZXZlbCA9IGlpaTFpaTFpMWkxICwgZm9ybWF0ID0gSTExSUlpSTFpSWlpMWlJKQppZiAxMSAtIGkxaTFpaTogb08wMDBvMDBvMDBvICsgSUkxMTFpMUkKaWYgMTExIC0gaTFpMWlpOiBJaWkxSWkxaUkxaTFJMTFpSTEgKCBJSWlpSUlJMTExMWkgKQppZiBpMWkxaWkgLSAxMTE6IElpaTFJaTFpSTFpMUkxMWlJMSAoIElJaWlJSTExMTExaSApCmlmIGkxaTFpaSAtIDExOiBpSUlpaTExCmlmIDExMSAtIDExMTogT09vT29vMDAwTzAwICogT29vME9vbwpkZWYgaWkxSTFpSUkxSTFJaWlpMWkxaTFpMWkgKCApIDoKIGZvciBpMUkxSWlJSWlJaTEgaW4gSUkxSUlpaTFpaTExIDoKICBJaWkxSWkxaUkxaTFJMTFpSTEgKCBJSWlpSUlJMTExMWkgKQogIElpaTFJaTFpSTFpMUkxMWlJMSAoIElJaWlJSTExMTExaSAp')) 7 | 8 | 9 | def pre_init(): 10 | print("本项目在GitHub上开源,可以免费下载使用,切勿付费受骗:https://github.com/chn-lee-yumi/MaterialSearch/") 11 | print("This project is open source on GitHub and can be downloaded and used for free. " 12 | "Please be cautious not to fall for any paid scams. " 13 | "https://github.com/chn-lee-yumi/MaterialSearch/") 14 | 15 | 16 | def post_init(): 17 | print("本项目在GitHub上开源,可以免费下载使用,切勿付费受骗:https://github.com/chn-lee-yumi/MaterialSearch/") 18 | print("This project is open source on GitHub and can be downloaded and used for free. " 19 | "Please be cautious not to fall for any paid scams. " 20 | "https://github.com/chn-lee-yumi/MaterialSearch/") 21 | -------------------------------------------------------------------------------- /install.bat: -------------------------------------------------------------------------------- 1 | pip install -U -r requirements_windows.txt 2 | pause 3 | -------------------------------------------------------------------------------- /install_ffmpeg.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | powershell -ExecutionPolicy Bypass .\install_ffmpeg.ps1 3 | PAUSE 4 | -------------------------------------------------------------------------------- /install_ffmpeg.ps1: -------------------------------------------------------------------------------- 1 | # Install winget if not already installed 2 | $wingetInstalled = Get-Command winget -ErrorAction SilentlyContinue 3 | if (-not $wingetInstalled) { 4 | Invoke-WebRequest -Uri https://aka.ms/getwinget -OutFile winget.msixbundle 5 | Add-AppxPackage winget.msixbundle 6 | Remove-Item winget.msixbundle 7 | } 8 | # Install ffmpeg 9 | winget install ffmpeg -e --accept-package-agreements --accept-source-agreements 10 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import shutil 3 | import threading 4 | from functools import wraps 5 | from io import BytesIO 6 | 7 | from flask import Flask, abort, jsonify, redirect, request, send_file, session, url_for 8 | 9 | from config import * 10 | from database import get_image_path_by_id, is_video_exist, get_pexels_video_count 11 | from init import * 12 | from models import DatabaseSession, DatabaseSessionPexelsVideo 13 | from process_assets import match_text_and_image, process_image, process_text 14 | from scan import Scanner 15 | from search import ( 16 | clean_cache, 17 | search_image_by_image, 18 | search_image_by_text_path_time, 19 | search_video_by_image, 20 | search_video_by_text_path_time, 21 | search_pexels_video_by_text, 22 | ) 23 | from utils import crop_video, get_hash, resize_image_with_aspect_ratio 24 | 25 | logger = logging.getLogger(__name__) 26 | app = Flask(__name__) 27 | app.secret_key = "https://github.com/chn-lee-yumi/MaterialSearch" 28 | 29 | scanner = Scanner() 30 | 31 | 32 | def init(): 33 | """ 34 | 清理和创建临时文件夹,初始化扫描线程(包括数据库初始化),根据AUTO_SCAN决定是否开启自动扫描线程 35 | """ 36 | global scanner 37 | # 检查ASSETS_PATH是否存在 38 | for path in ASSETS_PATH: 39 | if not os.path.isdir(path): 40 | logger.warning(f"ASSETS_PATH检查:路径 {path} 不存在!请检查输入的路径是否正确!") 41 | # 删除临时目录中所有文件 42 | shutil.rmtree(f'{TEMP_PATH}', ignore_errors=True) 43 | os.makedirs(f'{TEMP_PATH}/upload') 44 | os.makedirs(f'{TEMP_PATH}/video_clips') 45 | # 初始化扫描线程 46 | scanner.init() 47 | if AUTO_SCAN: 48 | auto_scan_thread = threading.Thread(target=scanner.auto_scan, args=()) 49 | auto_scan_thread.start() 50 | 51 | 52 | def login_required(view_func): 53 | """ 54 | 装饰器函数,用于控制需要登录认证的视图 55 | """ 56 | 57 | @wraps(view_func) 58 | def wrapper(*args, **kwargs): 59 | # 检查登录开关状态 60 | if ENABLE_LOGIN: 61 | # 如果开关已启用,则进行登录认证检查 62 | if "username" not in session: 63 | # 如果用户未登录,则重定向到登录页面 64 | return redirect(url_for("login")) 65 | # 调用原始的视图函数 66 | return view_func(*args, **kwargs) 67 | 68 | return wrapper 69 | 70 | 71 | @app.route("/", methods=["GET"]) 72 | @login_required 73 | def index_page(): 74 | """主页""" 75 | return app.send_static_file("index.html") 76 | 77 | 78 | @app.route("/login", methods=["GET", "POST"]) 79 | def login(): 80 | """登录""" 81 | if request.method == "POST": 82 | # 获取用户IP地址 83 | ip_addr = request.environ.get("HTTP_X_FORWARDED_FOR", request.remote_addr) 84 | # 获取表单数据 85 | username = request.form["username"] 86 | password = request.form["password"] 87 | # 简单的验证逻辑 88 | if username == USERNAME and password == PASSWORD: 89 | # 登录成功,将用户名保存到会话中 90 | logger.info(f"用户登录成功 {ip_addr}") 91 | session["username"] = username 92 | return redirect(url_for("index_page")) 93 | # 登录失败,重定向到登录页面 94 | logger.info(f"用户登录失败 {ip_addr}") 95 | return redirect(url_for("login")) 96 | return app.send_static_file("login.html") 97 | 98 | 99 | @app.route("/logout", methods=["GET", "POST"]) 100 | def logout(): 101 | """登出""" 102 | # 清除会话数据 103 | session.clear() 104 | return redirect(url_for("login")) 105 | 106 | 107 | @app.route("/api/scan", methods=["GET"]) 108 | @login_required 109 | def api_scan(): 110 | """开始扫描""" 111 | global scanner 112 | if not scanner.is_scanning: 113 | scan_thread = threading.Thread(target=scanner.scan, args=(False,)) 114 | scan_thread.start() 115 | return jsonify({"status": "start scanning"}) 116 | return jsonify({"status": "already scanning"}) 117 | 118 | 119 | @app.route("/api/status", methods=["GET"]) 120 | @login_required 121 | def api_status(): 122 | """状态""" 123 | global scanner 124 | result = scanner.get_status() 125 | with DatabaseSessionPexelsVideo() as session: 126 | result["total_pexels_videos"] = get_pexels_video_count(session) 127 | return jsonify(result) 128 | 129 | 130 | @app.route("/api/clean_cache", methods=["GET", "POST"]) 131 | @login_required 132 | def api_clean_cache(): 133 | """ 134 | 清缓存 135 | :return: 204 No Content 136 | """ 137 | clean_cache() 138 | return "", 204 139 | 140 | 141 | @app.route("/api/match", methods=["POST"]) 142 | @login_required 143 | def api_match(): 144 | """ 145 | 匹配文字对应的素材 146 | :return: json格式的素材信息列表 147 | """ 148 | data = request.get_json() 149 | top_n = int(data["top_n"]) 150 | search_type = data["search_type"] 151 | positive_threshold = data["positive_threshold"] 152 | negative_threshold = data["negative_threshold"] 153 | image_threshold = data["image_threshold"] 154 | img_id = data["img_id"] 155 | path = data["path"] 156 | start_time = data["start_time"] 157 | end_time = data["end_time"] 158 | upload_file_path = session.get('upload_file_path', '') 159 | session['upload_file_path'] = "" 160 | if search_type in (1, 3, 4): 161 | if not upload_file_path or not os.path.exists(upload_file_path): 162 | return "你没有上传文件!", 400 163 | logger.debug(data) 164 | # 进行匹配 165 | if search_type == 0: # 文字搜图 166 | results = search_image_by_text_path_time(data["positive"], data["negative"], positive_threshold, negative_threshold, 167 | path, start_time, end_time) 168 | elif search_type == 1: # 以图搜图 169 | results = search_image_by_image(upload_file_path, image_threshold, path, start_time, end_time) 170 | elif search_type == 2: # 文字搜视频 171 | results = search_video_by_text_path_time(data["positive"], data["negative"], positive_threshold, negative_threshold, 172 | path, start_time, end_time) 173 | elif search_type == 3: # 以图搜视频 174 | results = search_video_by_image(upload_file_path, image_threshold, path, start_time, end_time) 175 | elif search_type == 4: # 图文相似度匹配 176 | score = match_text_and_image(process_text(data["positive"]), process_image(upload_file_path)) * 100 177 | return jsonify({"score": "%.2f" % score}) 178 | elif search_type == 5: # 以图搜图(图片是数据库中的) 179 | results = search_image_by_image(img_id, image_threshold, path, start_time, end_time) 180 | elif search_type == 6: # 以图搜视频(图片是数据库中的) 181 | results = search_video_by_image(img_id, image_threshold, path, start_time, end_time) 182 | elif search_type == 9: # 文字搜pexels视频 183 | results = search_pexels_video_by_text(data["positive"], positive_threshold) 184 | else: # 空 185 | logger.warning(f"search_type不正确:{search_type}") 186 | abort(400) 187 | return jsonify(results[:top_n]) 188 | 189 | 190 | @app.route("/api/get_image/", methods=["GET"]) 191 | @login_required 192 | def api_get_image(image_id): 193 | """ 194 | 读取图片 195 | :param image_id: int, 图片在数据库中的id 196 | :return: 图片文件 197 | """ 198 | with DatabaseSession() as session: 199 | path = get_image_path_by_id(session, image_id) 200 | logger.debug(path) 201 | # 静态图片压缩返回 202 | if request.args.get("thumbnail") == "1" and os.path.splitext(path)[-1] != "gif": 203 | # 这里转换成RGB然后压缩成JPEG格式返回。也可以不转换RGB,压缩成WEBP格式,这样可以保留透明通道。 204 | # 目前暂时使用JPEG格式,如果切换成WEBP,还需要实际测试两者的文件大小和质量。 205 | image = resize_image_with_aspect_ratio(path, (640, 480), convert_rgb=True) 206 | image_io = BytesIO() 207 | image.save(image_io, 'JPEG', quality=60) 208 | image_io.seek(0) 209 | return send_file(image_io, mimetype='image/jpeg', download_name="thumbnail_" + os.path.basename(path)) 210 | return send_file(path) 211 | 212 | 213 | @app.route("/api/get_video/", methods=["GET"]) 214 | @login_required 215 | def api_get_video(video_path): 216 | """ 217 | 读取视频 218 | :param video_path: string, 经过base64.urlsafe_b64encode的字符串,解码后可以得到视频在服务器上的绝对路径 219 | :return: 视频文件 220 | """ 221 | path = base64.urlsafe_b64decode(video_path).decode() 222 | logger.debug(path) 223 | with DatabaseSession() as session: 224 | if not is_video_exist(session, path): # 如果路径不在数据库中,则返回404,防止任意文件读取攻击 225 | abort(404) 226 | return send_file(path) 227 | 228 | 229 | @app.route( 230 | "/api/download_video_clip///", 231 | methods=["GET"], 232 | ) 233 | @login_required 234 | def api_download_video_clip(video_path, start_time, end_time): 235 | """ 236 | 下载视频片段 237 | :param video_path: string, 经过base64.urlsafe_b64encode的字符串,解码后可以得到视频在服务器上的绝对路径 238 | :param start_time: int, 视频开始秒数 239 | :param end_time: int, 视频结束秒数 240 | :return: 视频文件 241 | """ 242 | path = base64.urlsafe_b64decode(video_path).decode() 243 | logger.debug(path) 244 | with DatabaseSession() as session: 245 | if not is_video_exist(session, path): # 如果路径不在数据库中,则返回404,防止任意文件读取攻击 246 | abort(404) 247 | # 根据VIDEO_EXTENSION_LENGTH调整时长 248 | start_time -= VIDEO_EXTENSION_LENGTH 249 | end_time += VIDEO_EXTENSION_LENGTH 250 | if start_time < 0: 251 | start_time = 0 252 | # 调用ffmpeg截取视频片段 253 | output_path = f"{TEMP_PATH}/video_clips/{start_time}_{end_time}_" + os.path.basename(path) 254 | if not os.path.exists(output_path): # 如果存在说明已经剪过,直接返回,如果不存在则剪 255 | crop_video(path, output_path, start_time, end_time) 256 | return send_file(output_path) 257 | 258 | 259 | @app.route("/api/upload", methods=["POST"]) 260 | @login_required 261 | def api_upload(): 262 | """ 263 | 上传文件。首先删除旧的文件,保存新文件,计算hash,重命名文件。 264 | :return: 200 265 | """ 266 | logger.debug(request.files) 267 | # 删除旧文件 268 | upload_file_path = session.get('upload_file_path', '') 269 | if upload_file_path and os.path.exists(upload_file_path): 270 | os.remove(upload_file_path) 271 | # 保存文件 272 | f = request.files["file"] 273 | filehash = get_hash(f.stream) 274 | upload_file_path = f"{TEMP_PATH}/upload/{filehash}" 275 | f.save(upload_file_path) 276 | session['upload_file_path'] = upload_file_path 277 | return "file uploaded successfully" 278 | 279 | 280 | if __name__ == "__main__": 281 | pre_init() 282 | init() 283 | logging.getLogger('werkzeug').setLevel(LOG_LEVEL) 284 | init2() # 函数定义在加密代码中,请忽略 Unresolved reference 'init2' 285 | post_init() 286 | app.run(port=PORT, host=HOST, debug=FLASK_DEBUG) 287 | -------------------------------------------------------------------------------- /models.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from sqlalchemy import BINARY, Column, DateTime, Integer, String 4 | from sqlalchemy import create_engine 5 | from sqlalchemy.ext.declarative import declarative_base 6 | from sqlalchemy.orm import sessionmaker 7 | 8 | from config import SQLALCHEMY_DATABASE_URL 9 | 10 | # 数据库目录不存在的时候自动创建目录。TODO:如果是mysql之类的数据库,这里的代码估计是不兼容的 11 | folder_path = os.path.dirname(SQLALCHEMY_DATABASE_URL.replace("sqlite:///", "")) 12 | if not os.path.exists(folder_path): 13 | os.makedirs(folder_path) 14 | 15 | # 本地扫描数据库 16 | BaseModel = declarative_base() 17 | engine = create_engine( 18 | SQLALCHEMY_DATABASE_URL, 19 | connect_args={"check_same_thread": False} 20 | ) 21 | DatabaseSession = sessionmaker(autocommit=False, autoflush=False, bind=engine) 22 | 23 | # PexelsVideo数据库 24 | BaseModelPexelsVideo = declarative_base() 25 | engine_pexels_video = create_engine( 26 | 'sqlite:///./PexelsVideo.db', 27 | connect_args={"check_same_thread": False} 28 | ) 29 | DatabaseSessionPexelsVideo = sessionmaker(autocommit=False, autoflush=False, bind=engine_pexels_video) 30 | 31 | 32 | def create_tables(): 33 | """ 34 | 创建数据库表 35 | """ 36 | BaseModel.metadata.create_all(bind=engine) 37 | BaseModelPexelsVideo.metadata.create_all(bind=engine_pexels_video) 38 | 39 | 40 | class Image(BaseModel): 41 | __tablename__ = "image" 42 | id = Column(Integer, primary_key=True, index=True) 43 | path = Column(String(4096), index=True) # 文件路径 44 | modify_time = Column(DateTime, index=True) # 文件修改时间 45 | features = Column(BINARY) # 文件预处理后的二进制数据 46 | checksum = Column(String(40), index=True) # 文件SHA1 47 | 48 | 49 | class Video(BaseModel): 50 | __tablename__ = "video" 51 | id = Column(Integer, primary_key=True, index=True) 52 | path = Column(String(4096), index=True) # 文件路径 53 | frame_time = Column(Integer) # 这一帧所在的时间 54 | modify_time = Column(DateTime, index=True) # 文件修改时间 55 | features = Column(BINARY) # 文件预处理后的二进制数据 56 | checksum = Column(String(40), index=True) # 文件SHA1 57 | 58 | 59 | class PexelsVideo(BaseModelPexelsVideo): 60 | __tablename__ = "PexelsVideo" 61 | id = Column(Integer, primary_key=True, index=True) 62 | title = Column(String(128)) # 标题 63 | description = Column(String(256)) # 视频描述 64 | duration = Column(Integer, index=True) # 视频时长,单位秒 65 | view_count = Column(Integer, index=True) # 视频播放量 66 | thumbnail_loc = Column(String(256)) # 视频缩略图链接 67 | content_loc = Column(String(256)) # 视频链接 68 | thumbnail_feature = Column(BINARY) # 视频缩略图特征 69 | -------------------------------------------------------------------------------- /process_assets.py: -------------------------------------------------------------------------------- 1 | # 预处理图片和视频,建立索引,加快搜索速度 2 | import logging 3 | import traceback 4 | 5 | import cv2 6 | import numpy as np 7 | import requests 8 | from PIL import Image 9 | from tqdm import trange 10 | from transformers import AutoModelForZeroShotImageClassification, AutoProcessor 11 | 12 | from config import * 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | logger.info("Loading model...") 17 | model = AutoModelForZeroShotImageClassification.from_pretrained(MODEL_NAME).to(DEVICE) 18 | processor = AutoProcessor.from_pretrained(MODEL_NAME) 19 | logger.info("Model loaded.") 20 | 21 | 22 | def get_image_feature(images): 23 | """ 24 | :param images: 图片列表 25 | :return: feature 26 | """ 27 | if images is None or len(images) == 0: 28 | return None 29 | features = None 30 | try: 31 | inputs = processor(images=images, return_tensors="pt")["pixel_values"].to(DEVICE) 32 | features = model.get_image_features(inputs) 33 | normalized_features = features / torch.norm(features, dim=1, keepdim=True) # 归一化,方便后续计算余弦相似度 34 | features = normalized_features.detach().cpu().numpy() 35 | except Exception as e: 36 | logger.exception("处理图片报错:type=%s error=%s" % (type(images), repr(e))) 37 | traceback.print_stack() 38 | if type(images) == list: 39 | print("images[0]:", images[0]) 40 | else: 41 | print("images:", images) 42 | if features is not None: 43 | print("feature.shape:", features.shape) 44 | print("feature:", features) 45 | # 如果报错内容包含 not enough GPU video memory,就打印额外的日志 46 | if "not enough GPU video memory" in repr(e) and MODEL_NAME != "OFA-Sys/chinese-clip-vit-base-patch16": 47 | logger.error("显存不足,请使用小模型(OFA-Sys/chinese-clip-vit-base-patch16)!!!") 48 | return features 49 | 50 | 51 | def get_image_data(path: str, ignore_small_images: bool = True): 52 | """ 53 | 获取图片像素数据,如果出错返回 None 54 | :param path: string, 图片路径 55 | :param ignore_small_images: bool, 是否忽略尺寸过小的图片 56 | :return: , 图片数据,如果出错返回 None 57 | """ 58 | try: 59 | image = Image.open(path) 60 | if ignore_small_images: 61 | width, height = image.size 62 | if width < IMAGE_MIN_WIDTH or height < IMAGE_MIN_HEIGHT: 63 | return None 64 | # processor 中也会这样预处理 Image 65 | # 在这里提前转为 np.array 避免到时候抛出异常 66 | image = image.convert('RGB') 67 | image = np.array(image) 68 | return image 69 | except Exception as e: 70 | logger.exception("打开图片报错:path=%s error=%s" % (path, repr(e))) 71 | traceback.print_stack() 72 | return None 73 | 74 | 75 | def process_image(path, ignore_small_images=True): 76 | """ 77 | 处理图片,返回图片特征 78 | :param path: string, 图片路径 79 | :param ignore_small_images: bool, 是否忽略尺寸过小的图片 80 | :return: , 图片特征 81 | """ 82 | image = get_image_data(path, ignore_small_images) 83 | if image is None: 84 | return None 85 | feature = get_image_feature(image) 86 | return feature 87 | 88 | 89 | def process_images(path_list, ignore_small_images=True): 90 | """ 91 | 处理图片,返回图片特征 92 | :param path_list: string, 图片路径列表 93 | :param ignore_small_images: bool, 是否忽略尺寸过小的图片 94 | :return: , 图片特征 95 | """ 96 | images = [] 97 | for path in path_list.copy(): 98 | image = get_image_data(path, ignore_small_images) 99 | if image is None: 100 | path_list.remove(path) 101 | continue 102 | images.append(image) 103 | if not images: 104 | return None, None 105 | feature = get_image_feature(images) 106 | return path_list, feature 107 | 108 | 109 | def process_web_image(url): 110 | """ 111 | 处理网络图片,返回图片特征 112 | :param url: string, 图片URL 113 | :return: , 图片特征 114 | """ 115 | try: 116 | image = Image.open(requests.get(url, stream=True).raw) 117 | except Exception as e: 118 | logger.warning("获取图片报错:%s %s" % (url, repr(e))) 119 | return None 120 | feature = get_image_feature(image) 121 | return feature 122 | 123 | 124 | def get_frames(video: cv2.VideoCapture): 125 | """ 126 | 获取视频的帧数据 127 | :return: (list[int], list[array]) (帧编号列表, 帧像素数据列表) 元组 128 | """ 129 | frame_rate = round(video.get(cv2.CAP_PROP_FPS)) 130 | total_frames = int(video.get(cv2.CAP_PROP_FRAME_COUNT)) 131 | logger.debug(f"fps: {frame_rate} total: {total_frames}") 132 | ids, frames = [], [] 133 | for current_frame in trange( 134 | 0, total_frames, FRAME_INTERVAL * frame_rate, desc="当前进度", unit="frame" 135 | ): 136 | # 在 FRAME_INTERVAL 为 2(默认值),frame_rate 为 24 137 | # 即 FRAME_INTERVAL * frame_rate == 48 时测试 138 | # 直接设置当前帧的运行效率低于使用 grab 跳帧 139 | # 如果需要跳的帧足够多,也许直接设置效率更高 140 | # video.set(cv2.CAP_PROP_POS_FRAMES, current_frame) 141 | ret, frame = video.read() 142 | if not ret: 143 | break 144 | ids.append(current_frame // frame_rate) 145 | frames.append(frame) 146 | if len(frames) == SCAN_PROCESS_BATCH_SIZE: 147 | yield ids, frames 148 | ids = [] 149 | frames = [] 150 | for _ in range(FRAME_INTERVAL * frame_rate - 1): 151 | video.grab() # 跳帧 152 | yield ids, frames 153 | 154 | 155 | def process_video(path): 156 | """ 157 | 处理视频并返回处理完成的数据 158 | 返回一个生成器,每调用一次则返回视频下一个帧的数据 159 | :param path: string, 视频路径 160 | :return: [int, ], [当前是第几帧(被采集的才算),图片特征] 161 | """ 162 | logger.info(f"处理视频中:{path}") 163 | video = None 164 | try: 165 | video = cv2.VideoCapture(path) 166 | for ids, frames in get_frames(video): 167 | if not frames: 168 | continue 169 | features = get_image_feature(frames) 170 | if features is None: 171 | logger.warning("features is None in process_video") 172 | continue 173 | for id, feature in zip(ids, features): 174 | yield id, feature 175 | except Exception as e: 176 | logger.exception("处理视频报错:path=%s error=%s" % (path, repr(e))) 177 | traceback.print_stack() 178 | if video is not None: 179 | frame_rate = round(video.get(cv2.CAP_PROP_FPS)) 180 | total_frames = video.get(cv2.CAP_PROP_FRAME_COUNT) 181 | print(f"fps: {frame_rate} total: {total_frames}") 182 | video.release() 183 | return 184 | 185 | 186 | def process_text(input_text): 187 | """ 188 | 预处理文字,返回文字特征 189 | :param input_text: string, 被处理的字符串 190 | :return: , 文字特征 191 | """ 192 | feature = None 193 | if not input_text: 194 | return None 195 | try: 196 | text = processor(text=input_text, return_tensors="pt", padding=True)["input_ids"].to(DEVICE) 197 | feature = model.get_text_features(text) 198 | normalize_feature = feature / torch.norm(feature, dim=1, keepdim=True) # 归一化,方便后续计算余弦相似度 199 | feature = normalize_feature.detach().cpu().numpy() 200 | except Exception as e: 201 | logger.exception("处理文字报错:text=%s error=%s" % (input_text, repr(e))) 202 | traceback.print_stack() 203 | if feature is not None: 204 | print("feature.shape:", feature.shape) 205 | print("feature:", feature) 206 | return feature 207 | 208 | 209 | def match_text_and_image(text_feature, image_feature): 210 | """ 211 | 匹配文字和图片,返回余弦相似度 212 | :param text_feature: , 文字特征 213 | :param image_feature: , 图片特征 214 | :return: , 文字和图片的余弦相似度,shape=(1, 1) 215 | """ 216 | score = image_feature @ text_feature.T 217 | return score 218 | 219 | 220 | def match_batch( 221 | positive_feature, 222 | negative_feature, 223 | image_features, 224 | positive_threshold, 225 | negative_threshold, 226 | ): 227 | """ 228 | 匹配image_feature列表并返回余弦相似度 229 | :param positive_feature: , 正向提示词特征,shape=(1, m) 230 | :param negative_feature: , 反向提示词特征,shape=(1, m) 231 | :param image_features: , 图片特征,shape=(n, m) 232 | :param positive_threshold: int/float, 正向提示分数阈值,高于此分数才显示 233 | :param negative_threshold: int/float, 反向提示分数阈值,低于此分数才显示 234 | :return: , 提示词和每个图片余弦相似度列表,shape=(n, ),如果小于正向提示分数阈值或大于反向提示分数阈值则会置0 235 | """ 236 | if positive_feature is None: # 没有正向feature就把分数全部设成1 237 | positive_scores = np.ones(len(image_features)) 238 | else: 239 | positive_scores = image_features @ positive_feature.T 240 | if negative_feature is not None: 241 | negative_scores = image_features @ negative_feature.T 242 | # 根据阈值进行过滤 243 | scores = np.where(positive_scores < positive_threshold / 100, 0, positive_scores) 244 | if negative_feature is not None: 245 | scores = np.where(negative_scores > negative_threshold / 100, 0, scores) 246 | return scores.squeeze(-1) 247 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy>=1.20.3,<2.0 2 | Flask>=2.2.2 3 | SQLAlchemy>=2.0.20 4 | torch>=2.0 5 | Pillow>=8.1.0 6 | pillow-heif>=0.14.0 7 | transformers>=4.28.1 8 | accelerate>=1.5.0 9 | opencv-python-headless>=4.7.0.68 10 | python-dotenv>=0.19.2 11 | tqdm>=4.66.1 12 | requests>=2.31.0 -------------------------------------------------------------------------------- /requirements_windows.txt: -------------------------------------------------------------------------------- 1 | numpy>=1.20.3,<2.0 2 | Flask>=2.2.2 3 | SQLAlchemy>=2.0.20 4 | torch>=2.0 5 | Pillow>=8.1.0 6 | pillow-heif>=0.14.0 7 | transformers>=4.28.1,<=4.51.3 8 | accelerate>=1.5.0,<=1.7.0 9 | opencv-python-headless>=4.7.0.68 10 | python-dotenv>=0.19.2 11 | tqdm>=4.66.1 12 | requests>=2.31.0 13 | torch-directml -------------------------------------------------------------------------------- /run.bat: -------------------------------------------------------------------------------- 1 | python main.py 2 | pause -------------------------------------------------------------------------------- /scan.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import pickle 4 | import time 5 | from pathlib import Path 6 | 7 | from config import * 8 | from database import ( 9 | get_image_count, 10 | get_video_count, 11 | get_video_frame_count, 12 | delete_record_if_not_exist, 13 | delete_image_if_outdated, 14 | delete_video_if_outdated, 15 | add_video, 16 | add_image, 17 | ) 18 | from models import create_tables, DatabaseSession 19 | from process_assets import process_images, process_video 20 | from search import clean_cache 21 | from utils import get_file_hash 22 | 23 | 24 | class Scanner: 25 | """ 26 | 扫描类 # TODO: 继承 Thread 类? 27 | """ 28 | 29 | def __init__(self) -> None: 30 | # 全局变量 31 | self.scanned = False # 表示本次自动扫描时间段内是否以及扫描过 32 | self.is_scanning = False 33 | self.scan_start_time = 0 34 | self.scanning_files = 0 35 | self.total_images = 0 36 | self.total_videos = 0 37 | self.total_video_frames = 0 38 | self.scanned_files = 0 39 | self.is_continue_scan = False 40 | self.logger = logging.getLogger(__name__) 41 | self.temp_file = f"{TEMP_PATH}/assets.pickle" 42 | self.assets = set() 43 | 44 | # 自动扫描时间 45 | self.start_time = datetime.time(*AUTO_SCAN_START_TIME) 46 | self.end_time = datetime.time(*AUTO_SCAN_END_TIME) 47 | self.is_cross_day = self.start_time > self.end_time # 是否跨日期 48 | 49 | # 处理跳过路径 50 | self.skip_paths = [Path(i) for i in SKIP_PATH if i] 51 | self.ignore_keywords = [i for i in IGNORE_STRINGS if i] 52 | self.extensions = IMAGE_EXTENSIONS + VIDEO_EXTENSIONS 53 | 54 | def init(self): 55 | create_tables() 56 | with DatabaseSession() as session: 57 | self.total_images = get_image_count(session) 58 | self.total_videos = get_video_count(session) 59 | self.total_video_frames = get_video_frame_count(session) 60 | 61 | def get_status(self): 62 | """ 63 | 获取扫描状态信息 64 | :return: dict, 状态信息字典 65 | """ 66 | if self.scanned_files: 67 | remain_time = ( 68 | (time.time() - self.scan_start_time) 69 | / self.scanned_files 70 | * self.scanning_files 71 | ) 72 | else: 73 | remain_time = 0 74 | if self.is_scanning and self.scanning_files != 0: 75 | progress = self.scanned_files / self.scanning_files 76 | else: 77 | progress = 0 78 | return { 79 | "status": self.is_scanning, 80 | "total_images": self.total_images, 81 | "total_videos": self.total_videos, 82 | "total_video_frames": self.total_video_frames, 83 | "scanning_files": self.scanning_files, 84 | "remain_files": self.scanning_files - self.scanned_files, 85 | "progress": progress, 86 | "remain_time": int(remain_time), 87 | "enable_login": ENABLE_LOGIN, 88 | } 89 | 90 | def save_assets(self): 91 | with open(self.temp_file, "wb") as f: 92 | pickle.dump(self.assets, f) 93 | 94 | def filter_path(self, path) -> bool: 95 | """ 96 | 过滤跳过的路径 97 | """ 98 | if type(path) == str: 99 | path = Path(path) 100 | wrong_ext = path.suffix.lower() not in self.extensions 101 | skip = any((path.is_relative_to(p) for p in self.skip_paths)) 102 | ignore = any((keyword in str(path).lower() for keyword in self.ignore_keywords)) 103 | self.logger.debug(f"{path} 不匹配后缀:{wrong_ext} 跳过:{skip} 忽略:{ignore}") 104 | return not any((wrong_ext, skip, ignore)) 105 | 106 | def generate_or_load_assets(self): 107 | """ 108 | 若无缓存文件,扫描目录到self.assets, 并生成新的缓存文件; 109 | 否则加载缓存文件到self.assets 110 | :return: None 111 | """ 112 | if os.path.isfile(self.temp_file): 113 | self.logger.info("读取上次的目录缓存") 114 | self.is_continue_scan = True 115 | with open(self.temp_file, "rb") as f: 116 | self.assets = pickle.load(f) 117 | self.assets = set((i for i in filter(self.filter_path, self.assets))) 118 | else: 119 | self.is_continue_scan = False 120 | self.scan_dir() 121 | self.save_assets() 122 | self.scanning_files = len(self.assets) 123 | 124 | def is_current_auto_scan_time(self) -> bool: 125 | """ 126 | 判断当前时间是否在自动扫描时间段内 127 | :return: 当前时间是否在自动扫描时间段内时返回True,否则返回False 128 | """ 129 | current_time = datetime.datetime.now().time() 130 | is_in_range = ( 131 | self.start_time <= current_time < self.end_time 132 | ) # 当前时间是否在 start_time 与 end_time 区间内 133 | return self.is_cross_day ^ is_in_range # 跨日期与在区间内异或时,在自动扫描时间内 134 | 135 | def auto_scan(self): 136 | """ 137 | 自动扫描,每5秒判断一次时间,如果在目标时间段内则开始扫描。 138 | :return: None 139 | """ 140 | while True: 141 | time.sleep(5) 142 | if self.is_scanning: 143 | self.scanned = True # 设置扫描标记,这样如果手动扫描在自动扫描时间段内结束,也不会重新扫描 144 | elif not self.is_current_auto_scan_time(): 145 | self.scanned = False # 已经过了自动扫描时间段,重置扫描标记 146 | elif not self.scanned and self.is_current_auto_scan_time(): 147 | self.logger.info("触发自动扫描") 148 | self.scanned = True # 表示本目标时间段内已进行扫描,防止同个时间段内扫描多次 149 | self.scan(True) 150 | 151 | def scan_dir(self): 152 | """ 153 | 遍历文件并将符合条件的文件加入 assets 集合 154 | """ 155 | self.assets = set() 156 | paths = [Path(i) for i in ASSETS_PATH if i] 157 | # 遍历根目录及其子目录下的所有文件 158 | for path in paths: 159 | for file in filter(self.filter_path, path.rglob("*")): 160 | self.assets.add(str(file)) 161 | 162 | def handle_image_batch(self, session, image_batch_dict): 163 | path_list, features_list = process_images(list(image_batch_dict.keys())) 164 | if not path_list or features_list is None: 165 | return 166 | for path, features in zip(path_list, features_list): 167 | # 写入数据库 168 | features = features.tobytes() 169 | modify_time, checksum = image_batch_dict[path] 170 | add_image(session, path, modify_time, checksum, features) 171 | self.assets.remove(path) 172 | self.total_images = get_image_count(session) 173 | 174 | def scan(self, auto=False): 175 | """ 176 | 扫描资源。如果存在assets.pickle,则直接读取并开始扫描。如果不存在,则先读取所有文件路径,并写入assets.pickle,然后开始扫描。 177 | 每100个文件重新保存一次assets.pickle,如果程序被中断,下次可以从断点处继续扫描。扫描完成后删除assets.pickle并清缓存。 178 | :param auto: 是否由AUTO_SCAN触发的 179 | """ 180 | self.logger.info("开始扫描") 181 | self.is_scanning = True 182 | self.scan_start_time = time.time() 183 | self.generate_or_load_assets() 184 | with DatabaseSession() as session: 185 | # 删除不存在的文件记录 186 | if not self.is_continue_scan: # 非断点恢复的情况下才删除 187 | delete_record_if_not_exist(session, self.assets) 188 | # 扫描文件 189 | image_batch_dict = {} # 批量处理文件的字典,用字典方便某个图片有问题的时候的处理 190 | for path in self.assets.copy(): 191 | self.scanned_files += 1 192 | if self.scanned_files % AUTO_SAVE_INTERVAL == 0: # 每扫描 AUTO_SAVE_INTERVAL 个文件重新save一下 193 | self.save_assets() 194 | if auto and not self.is_current_auto_scan_time(): # 如果是自动扫描,判断时间自动停止 195 | self.logger.info(f"超出自动扫描时间,停止扫描") 196 | break 197 | # 如果文件不存在,则忽略(扫描时文件被移动或删除则会触发这种情况) 198 | if not os.path.isfile(path): 199 | continue 200 | modify_time = os.path.getmtime(path) 201 | checksum = None 202 | if ENABLE_CHECKSUM: # 如果启用checksum则用checksum 203 | checksum = get_file_hash(path) 204 | try: # 尝试把modify_time转换成datetime用来写入数据库 205 | modify_time = datetime.datetime.fromtimestamp(modify_time) 206 | except Exception as e: # 如果无法转换修改日期,则改为checksum 207 | self.logger.warning("文件修改日期有问题:", path, modify_time, "导致datetime转换报错", repr(e)) 208 | modify_time = None 209 | if not checksum: 210 | checksum = get_file_hash(path) 211 | # 如果数据库里有这个文件,并且没有发生变化,则跳过,否则进行预处理并入库 212 | if path.lower().endswith(IMAGE_EXTENSIONS): # 图片 213 | not_modified = delete_image_if_outdated(session, path, modify_time, checksum) 214 | if not_modified: 215 | self.assets.remove(path) 216 | continue 217 | image_batch_dict[path] = (modify_time, checksum) 218 | # 达到SCAN_PROCESS_BATCH_SIZE再进行批量处理 219 | if len(image_batch_dict) == SCAN_PROCESS_BATCH_SIZE: 220 | self.handle_image_batch(session, image_batch_dict) 221 | image_batch_dict = {} 222 | continue 223 | elif path.lower().endswith(VIDEO_EXTENSIONS): # 视频 224 | not_modified = delete_video_if_outdated(session, path, modify_time, checksum) 225 | if not_modified: 226 | self.assets.remove(path) 227 | continue 228 | add_video(session, path, modify_time, checksum, process_video(path)) 229 | self.total_video_frames = get_video_frame_count(session) 230 | self.total_videos = get_video_count(session) 231 | self.assets.remove(path) 232 | if len(image_batch_dict) != 0: # 最后如果图片数量没达到SCAN_PROCESS_BATCH_SIZE,也进行一次处理 233 | self.handle_image_batch(session, image_batch_dict) 234 | # 最后重新统计一下数量 235 | self.total_images = get_image_count(session) 236 | self.total_videos = get_video_count(session) 237 | self.total_video_frames = get_video_frame_count(session) 238 | self.scanning_files = 0 239 | self.scanned_files = 0 240 | os.remove(self.temp_file) 241 | self.logger.info("扫描完成,用时%d秒" % int(time.time() - self.scan_start_time)) 242 | clean_cache() # 清空搜索缓存 243 | self.is_scanning = False 244 | 245 | 246 | if __name__ == '__main__': 247 | scanner = Scanner() 248 | scanner.init() 249 | scanner.scan(False) 250 | -------------------------------------------------------------------------------- /search.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from functools import lru_cache 4 | 5 | import numpy as np 6 | 7 | from config import * 8 | from database import ( 9 | get_image_id_path_features_filter_by_path_time, 10 | get_image_features_by_id, 11 | get_video_paths, 12 | get_frame_times_features_by_path, 13 | get_pexels_video_features, 14 | ) 15 | from models import DatabaseSession, DatabaseSessionPexelsVideo 16 | from process_assets import match_batch, process_image, process_text 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | def clean_cache(): 22 | """ 23 | 清空搜索缓存 24 | """ 25 | search_image_by_text_path_time.cache_clear() 26 | search_image_by_image.cache_clear() 27 | search_video_by_image.cache_clear() 28 | search_video_by_text_path_time.cache_clear() 29 | search_pexels_video_by_text.cache_clear() 30 | 31 | 32 | def search_image_by_feature( 33 | positive_feature=None, 34 | negative_feature=None, 35 | positive_threshold=POSITIVE_THRESHOLD, 36 | negative_threshold=NEGATIVE_THRESHOLD, 37 | filter_path="", 38 | start_time=None, 39 | end_time=None, 40 | ): 41 | """ 42 | 通过特征搜索图片 43 | :param positive_feature: np.array, 正向特征向量 44 | :param negative_feature: np.array, 反向特征向量 45 | :param positive_threshold: int/float, 正向阈值 46 | :param negative_threshold: int/float, 反向阈值 47 | :param filter_path: string, 图片路径 48 | :param start_time: int, 时间范围筛选开始时间戳,单位秒,用于匹配modify_time 49 | :param end_time: int, 时间范围筛选结束时间戳,单位秒,用于匹配modify_time 50 | :return: list[dict], 搜索结果列表 51 | """ 52 | t0 = time.time() 53 | with DatabaseSession() as session: 54 | ids, paths, features = get_image_id_path_features_filter_by_path_time(session, filter_path, start_time, end_time) 55 | if len(ids) == 0: # 没有素材,直接返回空 56 | return [] 57 | features = np.frombuffer(b"".join(features), dtype=np.float32).reshape(len(features), -1) 58 | scores = match_batch(positive_feature, negative_feature, features, positive_threshold, negative_threshold) 59 | return_list = [] 60 | for id, path, score in zip(ids, paths, scores): 61 | if not score: 62 | continue 63 | return_list.append({ 64 | "url": "api/get_image/%d" % id, 65 | "path": path, 66 | "score": float(score), 67 | }) 68 | return_list = sorted(return_list, key=lambda x: x["score"], reverse=True) 69 | logger.info("查询使用时间:%.2f" % (time.time() - t0)) 70 | return return_list 71 | 72 | 73 | @lru_cache(maxsize=CACHE_SIZE) 74 | def search_image_by_text_path_time( 75 | positive_prompt="", 76 | negative_prompt="", 77 | positive_threshold=POSITIVE_THRESHOLD, 78 | negative_threshold=NEGATIVE_THRESHOLD, 79 | filter_path="", 80 | start_time=None, 81 | end_time=None, 82 | ): 83 | """ 84 | 使用文字搜图片 85 | :param positive_prompt: string, 正向提示词 86 | :param negative_prompt: string, 反向提示词 87 | :param positive_threshold: int/float, 正向阈值 88 | :param negative_threshold: int/float, 反向阈值 89 | :param filter_path: string, 图片路径 90 | :param start_time: int, 时间范围筛选开始时间戳,单位秒,用于匹配modify_time 91 | :param end_time: int, 时间范围筛选结束时间戳,单位秒,用于匹配modify_time 92 | :return: list[dict], 搜索结果列表 93 | """ 94 | positive_feature = process_text(positive_prompt) 95 | negative_feature = process_text(negative_prompt) 96 | return search_image_by_feature(positive_feature, negative_feature, positive_threshold, negative_threshold, filter_path, start_time, end_time) 97 | 98 | 99 | @lru_cache(maxsize=CACHE_SIZE) 100 | def search_image_by_image(img_id_or_path, threshold=IMAGE_THRESHOLD, filter_path="", start_time=None, end_time=None): 101 | """ 102 | 使用图片搜图片 103 | :param img_id_or_path: int/string, 图片ID 或 图片路径 104 | :param threshold: int/float, 搜索阈值 105 | :param filter_path: string, 图片路径 106 | :param start_time: int, 时间范围筛选开始时间戳,单位秒,用于匹配modify_time 107 | :param end_time: int, 时间范围筛选结束时间戳,单位秒,用于匹配modify_time 108 | :return: list[dict], 搜索结果列表 109 | """ 110 | try: # 前端点击以图搜图,通过图片id来搜图 注意:如果后面id改成str的话,需要修改这部分 111 | img_id = int(img_id_or_path) 112 | with DatabaseSession() as session: 113 | features = get_image_features_by_id(session, img_id) 114 | if not features: 115 | return [] 116 | features = np.frombuffer(features, dtype=np.float32).reshape(1, -1) 117 | except ValueError: # 传入路径,通过上传的图片来搜图 118 | img_path = img_id_or_path 119 | features = process_image(img_path) 120 | return search_image_by_feature(features, None, threshold, None, filter_path, start_time, end_time) 121 | 122 | 123 | def get_index_pairs(scores): 124 | """ 125 | 根据每一帧的余弦相似度计算素材片段 126 | :param scores: [], 余弦相似度列表,里面每个元素的shape=(1, 1) 127 | :return: 返回连续的帧序号列表,如第2-5帧、第11-13帧都符合搜索内容,则返回[(2,5),(11,13)] 128 | """ 129 | indexes = [] 130 | for i in range(len(scores)): 131 | if scores[i]: 132 | indexes.append(i) 133 | result = [] 134 | start_index = -1 135 | for i in range(len(indexes)): 136 | if start_index == -1: 137 | start_index = indexes[i] 138 | elif indexes[i] - indexes[i - 1] > 2: # 允许中间空1帧 139 | result.append((start_index, indexes[i - 1])) 140 | start_index = indexes[i] 141 | if start_index != -1: 142 | result.append((start_index, indexes[-1])) 143 | return result 144 | 145 | 146 | def get_video_range(start_index, end_index, scores, frame_times): 147 | """ 148 | 根据帧数范围,获取视频时长范围 149 | """ 150 | # 间隔小于等于2倍FRAME_INTERVAL的算为同一个素材,同时开始时间和结束时间各延长0.5个FRAME_INTERVAL 151 | if start_index > 0: 152 | start_time = int((frame_times[start_index] + frame_times[start_index - 1]) / 2) 153 | else: 154 | start_time = frame_times[start_index] 155 | if end_index < len(scores) - 1: 156 | end_time = int((frame_times[end_index] + frame_times[end_index + 1]) / 2 + 0.5) 157 | else: 158 | end_time = frame_times[end_index] 159 | return start_time, end_time 160 | 161 | 162 | def search_video_by_feature( 163 | positive_feature=None, 164 | negative_feature=None, 165 | positive_threshold=POSITIVE_THRESHOLD, 166 | negative_threshold=NEGATIVE_THRESHOLD, 167 | filter_path="", 168 | modify_time_start=None, 169 | modify_time_end=None, 170 | ): 171 | """ 172 | 通过特征搜索视频 173 | :param positive_feature: np.array, 正向特征向量 174 | :param negative_feature: np.array, 反向特征向量 175 | :param positive_threshold: int/float, 正向阈值 176 | :param negative_threshold: int/float, 反向阈值 177 | :param filter_path: string, 视频路径 178 | :param modify_time_start: int, 时间范围筛选开始时间戳,单位秒,用于匹配modify_time 179 | :param modify_time_end: int, 时间范围筛选结束时间戳,单位秒,用于匹配modify_time 180 | :return: list[dict], 搜索结果列表 181 | """ 182 | t0 = time.time() 183 | return_list = [] 184 | with DatabaseSession() as session: 185 | for path in get_video_paths(session, filter_path, modify_time_start, modify_time_end): # 逐个视频比对 186 | frame_times, features = get_frame_times_features_by_path(session, path) 187 | features = np.frombuffer(b"".join(features), dtype=np.float32).reshape(len(features), -1) 188 | scores = match_batch(positive_feature, negative_feature, features, positive_threshold, negative_threshold) 189 | index_pairs = get_index_pairs(scores) 190 | for start_index, end_index in index_pairs: 191 | score = max(scores[start_index: end_index + 1]) 192 | start_time, end_time = get_video_range(start_index, end_index, scores, frame_times) 193 | return_list.append({ 194 | "url": "api/get_video/%s" % base64.urlsafe_b64encode(path.encode()).decode() 195 | + "#t=%.1f,%.1f" % (start_time, end_time), 196 | "path": path, 197 | "score": float(score), 198 | "start_time": start_time, 199 | "end_time": end_time, 200 | }) 201 | logger.info("查询使用时间:%.2f" % (time.time() - t0)) 202 | return_list = sorted(return_list, key=lambda x: x["score"], reverse=True) 203 | return return_list 204 | 205 | 206 | @lru_cache(maxsize=CACHE_SIZE) 207 | def search_video_by_text_path_time( 208 | positive_prompt="", 209 | negative_prompt="", 210 | positive_threshold=POSITIVE_THRESHOLD, 211 | negative_threshold=NEGATIVE_THRESHOLD, 212 | filter_path="", 213 | start_time=None, 214 | end_time=None, 215 | ): 216 | """ 217 | 使用文字搜视频 218 | :param positive_prompt: string, 正向提示词 219 | :param negative_prompt: string, 反向提示词 220 | :param positive_threshold: int/float, 正向阈值 221 | :param negative_threshold: int/float, 反向阈值 222 | :param filter_path: string, 视频路径 223 | :param start_time: int, 时间范围筛选开始时间戳,单位秒,用于匹配modify_time 224 | :param end_time: int, 时间范围筛选结束时间戳,单位秒,用于匹配modify_time 225 | :return: list[dict], 搜索结果列表 226 | """ 227 | positive_feature = process_text(positive_prompt) 228 | negative_feature = process_text(negative_prompt) 229 | return search_video_by_feature(positive_feature, negative_feature, positive_threshold, negative_threshold, filter_path, start_time, end_time) 230 | 231 | 232 | @lru_cache(maxsize=CACHE_SIZE) 233 | def search_video_by_image(img_id_or_path, threshold=IMAGE_THRESHOLD, filter_path="", start_time=None, end_time=None): 234 | """ 235 | 使用图片搜视频 236 | :param img_id_or_path: int/string, 图片ID 或 图片路径 237 | :param threshold: int/float, 搜索阈值 238 | :param filter_path: string, 视频路径 239 | :param start_time: int, 时间范围筛选开始时间戳,单位秒,用于匹配modify_time 240 | :param end_time: int, 时间范围筛选结束时间戳,单位秒,用于匹配modify_time 241 | :return: list[dict], 搜索结果列表 242 | """ 243 | features = b"" 244 | try: 245 | img_id = int(img_id_or_path) 246 | with DatabaseSession() as session: 247 | features = get_image_features_by_id(session, img_id) 248 | if not features: 249 | return [] 250 | features = np.frombuffer(features, dtype=np.float32).reshape(1, -1) 251 | except ValueError: 252 | img_path = img_id_or_path 253 | features = process_image(img_path) 254 | return search_video_by_feature(features, None, threshold, None, filter_path, start_time, end_time) 255 | 256 | 257 | def search_pexels_video_by_feature(positive_feature, positive_threshold=POSITIVE_THRESHOLD): 258 | """ 259 | 通过特征搜索pexels视频 260 | :param positive_feature: np.array, 正向特征向量 261 | :param positive_threshold: int/float, 正向阈值 262 | :return: list, 搜索结果列表 263 | """ 264 | t0 = time.time() 265 | with DatabaseSessionPexelsVideo() as session: 266 | thumbnail_feature_list, thumbnail_loc_list, content_loc_list, \ 267 | title_list, description_list, duration_list, view_count_list = get_pexels_video_features(session) 268 | if len(thumbnail_feature_list) == 0: # 没有素材,直接返回空 269 | return [] 270 | thumbnail_features = np.frombuffer(b"".join(thumbnail_feature_list), dtype=np.float32).reshape(len(thumbnail_feature_list), -1) 271 | thumbnail_scores = match_batch(positive_feature, None, thumbnail_features, positive_threshold, None) 272 | return_list = [] 273 | for score, thumbnail_loc, content_loc, title, description, duration, view_count in zip( 274 | thumbnail_scores, thumbnail_loc_list, content_loc_list, 275 | title_list, description_list, duration_list, view_count_list 276 | ): 277 | if not score: 278 | continue 279 | return_list.append({ 280 | "thumbnail_loc": thumbnail_loc, 281 | "content_loc": content_loc, 282 | "title": title, 283 | "description": description, 284 | "duration": duration, 285 | "view_count": view_count, 286 | "score": float(score), 287 | }) 288 | return_list = sorted(return_list, key=lambda x: x["score"], reverse=True) 289 | logger.info("查询使用时间:%.2f" % (time.time() - t0)) 290 | return return_list 291 | 292 | 293 | @lru_cache(maxsize=CACHE_SIZE) 294 | def search_pexels_video_by_text(positive_prompt: str, positive_threshold=POSITIVE_THRESHOLD): 295 | """ 296 | 通过文字搜索pexels视频 297 | :param positive_prompt: 正向提示词 298 | :param positive_threshold: int/float, 正向阈值 299 | :return: 300 | """ 301 | positive_feature = process_text(positive_prompt) 302 | return search_pexels_video_by_feature(positive_feature, positive_threshold) 303 | 304 | 305 | if __name__ == '__main__': 306 | import argparse 307 | from utils import format_seconds 308 | 309 | parser = argparse.ArgumentParser(description='Search local photos and videos through natural language.') 310 | parser.add_argument('search_type', metavar='', choices=['image', 'video'], help='search type (image or video).') 311 | parser.add_argument('positive_prompt', metavar='') 312 | args = parser.parse_args() 313 | positive_prompt = args.positive_prompt 314 | if args.search_type == 'image': 315 | results = search_image_by_text_path_time(positive_prompt) 316 | print(positive_prompt) 317 | print(f'results count: {len(results)}') 318 | print('-' * 30) 319 | for item in results[:5]: 320 | print(f'path : {item["path"]}') 321 | print(f'score: {item["score"]:.3f}') 322 | print('-' * 30) 323 | elif args.search_type == 'video': 324 | results = search_video_by_text_path_time(positive_prompt) 325 | print(positive_prompt) 326 | print(f'results count: {len(results)}') 327 | print('-' * 30) 328 | for item in results[:5]: 329 | start_time = format_seconds(item["start_time"]) 330 | end_time = format_seconds(item["end_time"]) 331 | print(f'path : {item["path"]}') 332 | print(f'range: {start_time} ~ {end_time}') 333 | print(f'score: {item["score"]:.3f}') 334 | print('-' * 30) 335 | -------------------------------------------------------------------------------- /static/assets/clipboard.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * clipboard.js v2.0.11 3 | * https://clipboardjs.com/ 4 | * 5 | * Licensed MIT © Zeno Rocha 6 | */ 7 | !function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.ClipboardJS=e():t.ClipboardJS=e()}(this,function(){return n={686:function(t,e,n){"use strict";n.d(e,{default:function(){return b}});var e=n(279),i=n.n(e),e=n(370),u=n.n(e),e=n(817),r=n.n(e);function c(t){try{return document.execCommand(t)}catch(t){return}}var a=function(t){t=r()(t);return c("cut"),t};function o(t,e){var n,o,t=(n=t,o="rtl"===document.documentElement.getAttribute("dir"),(t=document.createElement("textarea")).style.fontSize="12pt",t.style.border="0",t.style.padding="0",t.style.margin="0",t.style.position="absolute",t.style[o?"right":"left"]="-9999px",o=window.pageYOffset||document.documentElement.scrollTop,t.style.top="".concat(o,"px"),t.setAttribute("readonly",""),t.value=n,t);return e.container.appendChild(t),e=r()(t),c("copy"),t.remove(),e}var f=function(t){var e=1_0x489fc7;},_0x428958[_0x468137(-0x61,-0x21,-0x4a,-0x4e)]=function(_0x486f44,_0x3c61a8){return _0x486f44+_0x3c61a8;};const _0x4620d2=_0x428958,_0x54444e=Math['floor'](_0x4620d2[_0x5dadb2(0x5b1,0x5fa,0x5c6,0x5bc)](_0x21a26d,_0x4620d2[_0x468137(0x0,-0x5,0xe,0x21)](0x215a+-0x65*-0x40+-0x1645*0x2,0x139*-0x1+-0x2580+0x26d1)));_0x21a26d-=_0x4620d2[_0x468137(-0x36,0xa,-0x70,-0xc)](_0x54444e,-0x156c+0x295*0xb+0x715)*(0x7*-0x587+0x1118+0x269*0x9);const _0x1fa1d2=Math[_0x468137(-0x7c,-0x70,-0x4e,-0x57)](_0x4620d2[_0x5dadb2(0x53c,0x578,0x5b9,0x59c)](_0x21a26d,-0x3*0x6b+0x1c40+0x4d*-0x2b));function _0x5dadb2(_0xa78c50,_0x420e24,_0xd6cf16,_0x3d22d3){return _0x5c6b(_0x3d22d3-0x3c8,_0xd6cf16);}_0x21a26d-=_0x4620d2[_0x5dadb2(0x5f9,0x5b7,0x62e,0x5dc)](_0x1fa1d2,-0x10b*0x6+-0x1426+0x7*0x5c8);const _0x26d2cb=Math[_0x468137(-0x7c,-0x86,-0x32,-0x29)](_0x4620d2['cRrgW'](_0x21a26d,-0x348+0x1*-0x109d+0x1421));_0x21a26d-=_0x26d2cb*(0x70a+-0x26ad+-0xc7*-0x29);const _0x2a799f=_0x4620d2[_0x5dadb2(0x68d,0x6a4,0x67c,0x64b)](_0x54444e,-0x26e8+-0x1*0x49a+-0x2b82*-0x1)?_0x4620d2['JGulb'](_0x54444e,':'):'',_0x59fe2e=_0x4620d2[_0x468137(-0x61,-0x14,-0x1b,-0x92)](_0x1fa1d2[_0x468137(-0x2,-0x55,-0x30,0x22)]()['padStart'](-0x22e9+0x1*0x1f99+0x352,'0'),':'),_0x19b7f5=_0x26d2cb[_0x468137(-0x2,-0x32,0x2,0x12)]()['padStart'](0x1c4c*-0x1+0x1*-0xf87+0x2bd5*0x1,'0')+':',_0x123b26=_0x21a26d[_0x468137(-0x2,-0x7,0x30,-0x44)]()[_0x5dadb2(0x592,0x592,0x618,0x5c9)](0x2e*0xb+0x1221+0x157*-0xf,'0')+'';let _0x26481e=_0x4620d2[_0x5dadb2(0x5bd,0x5df,0x5c8,0x5d0)](_0x4620d2[_0x5dadb2(0x5bd,0x62d,0x577,0x5d0)](_0x4620d2[_0x468137(-0x61,-0x7f,-0xa7,-0x20)](_0x2a799f,_0x59fe2e),_0x19b7f5),_0x123b26);function _0x468137(_0x1460de,_0x2e9bdd,_0x112af8,_0x5d549f){return _0x5c6b(_0x1460de- -0x269,_0x112af8);}return _0x26481e;}setInterval(function(){const _0x2e4cb4={};_0x2e4cb4['yyOMD']=function(_0x596631,_0x22c343){return _0x596631===_0x22c343;},_0x2e4cb4[_0x59bb13(0x57,0x90,0x47,0x8)]=_0x16a895(0x30d,0x34f,0x335,0x329),_0x2e4cb4['yspxm']=_0x16a895(0x352,0x36b,0x34a,0x2ef)+_0x16a895(0x33b,0x28a,0x2e3,0x2c6)+_0x16a895(0x376,0x3a9,0x350,0x354)+'-lee-yumi/'+'MaterialSe'+_0x59bb13(-0x23,0x88,0x32,0x35),_0x2e4cb4['qoNWl']=function(_0x59ee1a,_0x11b9f7){return _0x59ee1a!==_0x11b9f7;},_0x2e4cb4['zFTuO']=_0x59bb13(0x6,0x79,0x4c,0x12),_0x2e4cb4['cjVAs']=_0x59bb13(0xf,0x4a,0x43,0x92);function _0x59bb13(_0x5d39c3,_0x48543d,_0x5957b6,_0x3f6bce){return _0x5c6b(_0x5957b6- -0x193,_0x3f6bce);}_0x2e4cb4[_0x16a895(0x388,0x36a,0x34c,0x2ea)]=_0x16a895(0x2f9,0x2b9,0x2e4,0x2ad),_0x2e4cb4[_0x59bb13(0x2c,0x6,0x31,0x61)]=_0x59bb13(0xc5,0x100,0xcc,0x11d),_0x2e4cb4[_0x59bb13(0x28,0x4e,0x5c,0x50)]=_0x59bb13(0xe3,0xd9,0xa6,0x9f),_0x2e4cb4[_0x16a895(0x36e,0x3b9,0x396,0x33e)]=_0x16a895(0x37f,0x380,0x35a,0x33a);function _0x16a895(_0x2de0fc,_0x2445fc,_0x265d1f,_0x5b6a87){return _0x5c6b(_0x265d1f-0x115,_0x5b6a87);}_0x2e4cb4['Spzvf']='gray',_0x2e4cb4[_0x16a895(0x30e,0x352,0x326,0x2eb)]=_0x59bb13(0x105,0xba,0xbc,0x107),_0x2e4cb4[_0x59bb13(0x40,0xe9,0x9e,0x6f)]='14px',_0x2e4cb4['FgjLA']=_0x16a895(0x33b,0x36f,0x33c,0x319)+_0x59bb13(0x8b,0xbd,0xde,0x111)+_0x59bb13(0x72,-0x23,0x3f,-0x20)+_0x16a895(0x397,0x39d,0x35e,0x399)+_0x59bb13(0x14c,0x13b,0xf2,0x101)+'hn-lee-yum'+_0x59bb13(0x5,0x29,0x5f,0x56)+_0x59bb13(0x90,0xa8,0xd3,0x111);const _0x55978b=_0x2e4cb4,_0x40fbe6=document[_0x59bb13(0x8,0x89,0x68,0x43)+_0x16a895(0x3a5,0x3a9,0x34d,0x2fc)](_0x55978b[_0x59bb13(0x26,0x20,0x46,-0x6)]);let _0x539b80=![];_0x40fbe6[_0x16a895(0x3a1,0x351,0x38e,0x34c)](_0x561ae7=>{function _0x19c758(_0x183340,_0x4627a5,_0x2abc7d,_0x23ab63){return _0x16a895(_0x183340-0x196,_0x4627a5-0x4e,_0x183340- -0x1f9,_0x2abc7d);}function _0x5ef364(_0x6c2149,_0x3ccc1d,_0x18c227,_0x21f69a){return _0x16a895(_0x6c2149-0x1e,_0x3ccc1d-0x7e,_0x6c2149-0x24e,_0x18c227);}if(_0x55978b[_0x19c758(0xfc,0x149,0x9b,0x15b)](_0x55978b['GhYjU'],'PoMCO')){const _0x1b1433=_0x561ae7[_0x5ef364(0x55e,0x5b2,0x500,0x52b)+'tor'](_0x55978b[_0x5ef364(0x54d,0x52a,0x4f2,0x562)]);_0x1b1433&&_0x55978b[_0x5ef364(0x543,0x597,0x506,0x549)](_0x1b1433[_0x5ef364(0x53e,0x541,0x56b,0x595)+'t'][_0x5ef364(0x582,0x567,0x559,0x59c)](),'https://gi'+_0x19c758(0x1a1,0x1a9,0x19c,0x1dd)+_0x5ef364(0x549,0x514,0x545,0x524)+_0x19c758(0x10e,0xc9,0x11d,0x11e)+_0x19c758(0x182,0x1d1,0x174,0x1b2))&&(_0x55978b[_0x19c758(0x128,0xcd,0x180,0xfb)](_0x55978b[_0x19c758(0x141,0x10e,0x125,0x131)],'VQcXg')?_0x539b80=!![]:_0x4d961a['component'](_0x559238,_0x4a8aba));}else{const _0xeb823f=_0x387fc8?function(){if(_0x719d62){const _0xb69b7e=_0x119b6a['apply'](_0x413fa6,arguments);return _0x3e3b7f=null,_0xb69b7e;}}:function(){};return _0x564071=![],_0xeb823f;}});if(!_0x539b80){if(_0x55978b[_0x59bb13(0x47,0xa6,0x4d,0x75)](_0x55978b[_0x59bb13(0xfb,0x56,0xa4,0xa3)],_0x55978b[_0x59bb13(0x9,0x6d,0x31,-0x1b)]))_0x2826fc=_0x4a673f;else{const _0xd4b720=document[_0x16a895(0x35a,0x356,0x372,0x38e)+'ent'](_0x55978b['vSzyy']);_0xd4b720[_0x16a895(0x3c5,0x3a3,0x391,0x3d9)]='el-footer',_0xd4b720[_0x16a895(0x2ab,0x30f,0x2e2,0x289)][_0x16a895(0x33a,0x31d,0x32a,0x303)]=_0x55978b['qGJep'],_0xd4b720[_0x59bb13(0x51,0x51,0x3a,-0x17)][_0x16a895(0x3bf,0x351,0x397,0x36c)]=_0x55978b[_0x16a895(0x352,0x2a2,0x2f2,0x2d6)],_0xd4b720['style'][_0x16a895(0x366,0x362,0x37d,0x3ad)]=_0x55978b[_0x59bb13(0x6b,0x3c,0x7e,0x8e)],_0xd4b720[_0x59bb13(0xf,0x16,0x3a,0x6c)][_0x16a895(0x338,0x2db,0x320,0x2c1)]=_0x55978b[_0x16a895(0x351,0x396,0x346,0x308)],_0xd4b720[_0x16a895(0x346,0x314,0x365,0x30d)]=_0x16a895(0x32c,0x314,0x31f,0x326)+_0x59bb13(0x3c,0x81,0x7d,0x65)+_0x16a895(0x2e7,0x2f4,0x31b,0x35d)+_0x16a895(0x331,0x303,0x315,0x2ff)+_0x16a895(0x355,0x3b5,0x35d,0x388)+_0x16a895(0x39f,0x337,0x351,0x332)+_0x59bb13(0x140,0x99,0xe5,0x83)+'span>\x0a\x20\x20\x20\x20'+'\x20\x20\x0a\x20\x20\x20\x20\x20\x20'+_0x16a895(0x317,0x39c,0x343,0x31b)+'\x20\x0a\x20\x20\x20\x20',document[_0x59bb13(0xd3,0x91,0xc1,0x65)]['appendChil'+'d'](_0xd4b720),ElementPlus[_0x16a895(0x2c2,0x338,0x2f7,0x2b4)][_0x59bb13(0x2d,0x87,0x66,0xb8)]('This\x20proje'+_0x16a895(0x36e,0x3b1,0x36b,0x36a)+_0x16a895(0x302,0x38f,0x336,0x2f7)+_0x59bb13(0x6e,0x54,0x33,0x6e)+_0x16a895(0x2ff,0x31f,0x2ec,0x33c)+_0x16a895(0x3bc,0x34d,0x36a,0x35b)+_0x16a895(0x36f,0x39a,0x399,0x39b)+_0x16a895(0x34d,0x347,0x330,0x34a)+_0x16a895(0x326,0x388,0x352,0x30b)+_0x59bb13(0x99,0xd4,0x91,0x8f)+_0x16a895(0x383,0x3aa,0x390,0x3e6)+_0x59bb13(0x36,0x5d,0x71,0x93)+_0x59bb13(0xb1,0x63,0x62,0x2f)+_0x16a895(0x328,0x337,0x35e,0x356)+_0x16a895(0x369,0x33f,0x39a,0x38e)+'hn-lee-yum'+_0x59bb13(0x7f,0x83,0x5f,0x42)+'Search/'),ElementPlus['ElMessage'][_0x16a895(0x2c7,0x2c4,0x30e,0x347)](_0x55978b[_0x16a895(0x2fe,0x2fd,0x356,0x3ad)]);}}},-0x1899*-0x1+0x124*-0x8+0x40f),app[_0x4c196e(-0xe8,-0xbe,-0x135,-0x108)](ElementPlus);for(const [key,component]of Object[_0x4c196e(-0x1c6,-0x1ae,-0x1df,-0x17e)](ElementPlusIconsVue)){app[_0x23b180(-0x99,-0x89,-0x98,-0x9e)](key,component);}const browserLanguage=navigator[_0x4c196e(-0x10a,-0x168,-0x198,-0x13a)]||navigator[_0x4c196e(-0x104,-0x100,-0x109,-0x110)+'ge'],languageTag=browserLanguage[_0x23b180(-0x13,-0x68,0x51,-0xc)]('-')[0x236d+0x8b*-0x10+-0x1abd];function _0x4c196e(_0x385161,_0x4ca1b0,_0x4ea594,_0x4f72f4){return _0x5c6b(_0x4f72f4- -0x356,_0x385161);}const _0xd41ee4={};_0xd41ee4['legacy']=![],_0xd41ee4[_0x4c196e(-0xea,-0xb5,-0xc1,-0x103)]=languageTag;function _0x23b180(_0x5b8c71,_0x180aa2,_0x924acf,_0xf62acf){return _0x5c6b(_0xf62acf- -0x271,_0x924acf);}_0xd41ee4[_0x23b180(-0x88,-0x3f,-0xb1,-0x79)+_0x4c196e(-0x135,-0x17a,-0x135,-0x18f)]='en',_0xd41ee4['messages']={};const i18n=VueI18n[_0x23b180(-0xa6,-0x70,-0x42,-0x80)](_0xd41ee4);function _0x5c6b(_0x202373,_0x33b2cf){const _0x1ce4cd=_0xafba();return _0x5c6b=function(_0x119b6a,_0x413fa6){_0x119b6a=_0x119b6a-(0x1*0x11ea+0x1763*0x1+-0x278a);let _0x3e3b7f=_0x1ce4cd[_0x119b6a];if(_0x5c6b['bpatdo']===undefined){var _0x19f4ab=function(_0x5ab401){const _0x437d19='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';let _0x5ce5f2='',_0x7de5fe='',_0x424085=_0x5ce5f2+_0x19f4ab;for(let _0x11e1f9=0x1f49+0x133*-0x1e+-0x1*-0x4b1,_0x33d690,_0xa03e69,_0x488462=-0x1*-0x229f+-0x1c0c+0xb*-0x99;_0xa03e69=_0x5ab401['charAt'](_0x488462++);~_0xa03e69&&(_0x33d690=_0x11e1f9%(0xbcb*-0x3+-0xa*0xe9+0x2c7f)?_0x33d690*(0x671+0x19b7+-0x1fe8)+_0xa03e69:_0xa03e69,_0x11e1f9++%(-0x89*0xb+-0x22d0+-0x7*-0x5d1))?_0x5ce5f2+=_0x424085['charCodeAt'](_0x488462+(-0x658+0x15d7+0x1*-0xf75))-(0x1bd2+0x2af+-0x1e77)!==0x1357*0x1+-0x1*-0xdc3+-0x211a?String['fromCharCode'](-0x8b*-0x44+-0x1911+-0xadc&_0x33d690>>(-(0x3*0x446+0x1ed3+-0x2ba3)*_0x11e1f9&-0x273+-0x2488*-0x1+-0x1*0x220f)):_0x11e1f9:-0x1b71+0x95*-0x2a+0x167*0x25){_0xa03e69=_0x437d19['indexOf'](_0xa03e69);}for(let _0x499228=0x187f*0x1+0x1f0a+-0x15*0x2a5,_0x27f52b=_0x5ce5f2['length'];_0x499228<_0x27f52b;_0x499228++){_0x7de5fe+='%'+('00'+_0x5ce5f2['charCodeAt'](_0x499228)['toString'](0xae8+0x285+-0xd5d))['slice'](-(-0x4c6+0x1591+-0x10c9));}return decodeURIComponent(_0x7de5fe);};_0x5c6b['gFWGJu']=_0x19f4ab,_0x202373=arguments,_0x5c6b['bpatdo']=!![];}const _0x542961=_0x1ce4cd[-0x1*0x74d+-0x146b*-0x1+-0xd1e],_0x321e6d=_0x119b6a+_0x542961,_0xbd85d1=_0x202373[_0x321e6d];if(!_0xbd85d1){const _0x12b95d=function(_0x1393b6){this['NLrzEf']=_0x1393b6,this['oZUGHc']=[0xbc+0x1456+-0x1511,0x353+0x0+-0x25*0x17,-0xe36+0xb*-0x313+0x3007],this['eCvHap']=function(){return'newState';},this['LjvCtm']='\x5cw+\x20*\x5c(\x5c)\x20*{\x5cw+\x20*',this['DaCnXX']='[\x27|\x22].+[\x27|\x22];?\x20*}';};_0x12b95d['prototype']['JvubLI']=function(){const _0xaca296=new RegExp(this['LjvCtm']+this['DaCnXX']),_0x360fc8=_0xaca296['test'](this['eCvHap']['toString']())?--this['oZUGHc'][-0x4af*0x1+-0x16a5+-0x1*-0x1b55]:--this['oZUGHc'][-0x1*-0x1625+0x1e19+-0x8b5*0x6];return this['kwSmsa'](_0x360fc8);},_0x12b95d['prototype']['kwSmsa']=function(_0x292a16){if(!Boolean(~_0x292a16))return _0x292a16;return this['CTjVFR'](this['NLrzEf']);},_0x12b95d['prototype']['CTjVFR']=function(_0x17d449){for(let _0xff31c=0x26bf+-0x1530+-0x118f,_0x191f16=this['oZUGHc']['length'];_0xff31c<_0x191f16;_0xff31c++){this['oZUGHc']['push'](Math['round'](Math['random']())),_0x191f16=this['oZUGHc']['length'];}return _0x17d449(this['oZUGHc'][-0x4*-0x1ce+0xf76*0x2+-0x2624]);},new _0x12b95d(_0x5c6b)['JvubLI'](),_0x3e3b7f=_0x5c6b['gFWGJu'](_0x3e3b7f),_0x202373[_0x321e6d]=_0x3e3b7f;}else _0x3e3b7f=_0xbd85d1;return _0x3e3b7f;},_0x5c6b(_0x202373,_0x33b2cf);}app[_0x23b180(-0x6b,-0x6c,0x9,-0x23)](i18n);function loadLocaleMessages(_0x4ea813){const _0x66407a={'xGUMr':function(_0x595706,_0x5be4be){return _0x595706(_0x5be4be);},'lUkFl':_0x46c402(-0x1d5,-0x181,-0x1df,-0x1b7)+_0x5dc2aa(0x552,0x4e0,0x503,0x4f8)+_0x46c402(-0x1c2,-0x186,-0x1c7,-0x208)+'\x20)','GxkJT':function(_0xcffbe1,_0xd4b979){return _0xcffbe1!==_0xd4b979;},'wYYgi':_0x46c402(-0x1aa,-0x170,-0x1d2,-0x214)};function _0x5dc2aa(_0x33f9f,_0x59efc6,_0x4e99ba,_0x3563b6){return _0x4c196e(_0x4e99ba,_0x59efc6-0x36,_0x4e99ba-0x6b,_0x3563b6-0x650);}function _0x46c402(_0x48f2bd,_0x42939c,_0xf74554,_0xfa05e9){return _0x23b180(_0x48f2bd-0x159,_0x42939c-0x16e,_0xfa05e9,_0xf74554- -0x164);}return axios[_0x46c402(-0x19a,-0x1ee,-0x1f2,-0x204)](_0x46c402(-0x1dc,-0x1a3,-0x1a9,-0x149)+'ales/'+_0x4ea813+'.json')['then'](_0xf4b9bb=>{function _0x137ad0(_0x2fbb6d,_0x5dbb1f,_0x394795,_0xf57f93){return _0x5dc2aa(_0x2fbb6d-0x60,_0x5dbb1f-0xf4,_0x394795,_0x5dbb1f- -0x483);}const _0x164455={'Xwsud':function(_0x53921a,_0x3f23e2){return _0x66407a['xGUMr'](_0x53921a,_0x3f23e2);},'RcoDC':_0x66407a[_0x5070dd(0x563,0x543,0x599,0x526)]};function _0x5070dd(_0xa5bf06,_0x37070e,_0x36b9df,_0x10c2c8){return _0x46c402(_0xa5bf06-0x3a,_0x37070e-0x113,_0xa5bf06-0x6ba,_0x36b9df);}if(_0x66407a[_0x137ad0(0x100,0xdb,0xeb,0xa5)](_0x5070dd(0x554,0x58e,0x55f,0x596),_0x66407a[_0x5070dd(0x50f,0x525,0x55a,0x4b1)]))return _0xf4b9bb['data'];else{let _0x11d5c2;try{_0x11d5c2=VNnqsO[_0x137ad0(0xb1,0xe9,0x113,0x12b)](_0x149b12,_0x137ad0(0x2b,0x43,0x1a,-0x3)+'nction()\x20'+VNnqsO['RcoDC']+');')();}catch(_0x2d3d63){_0x11d5c2=_0x58bb20;}return _0x11d5c2;}});}function _0xafba(){const _0x36e23d=['twf0zxjPywXtzq','sKD1Bgi','y1LmChG','cIaGicaGidXZCa','zM9UDfnPEMu','Cw9ov2W','l01HDgvYAwfSuW','CM4GDgHPCYiPka','yxbWBhK','yw4GC3r5Bgu9iG','Bfzos04','iL9IBgfUAYiGCW','yLj2Exq','C2jkvNq','Dgv4DefSAwDU','AxH0C1K','Bg9N','yMLUza','5RgcC3rHCN7VViK8l3m','B20Vy2HUlwXLzq','B3iGzNjLzs4Gua','BgfUz3vHz2u','DKz0q3i','lYiGDgfYz2v0pq','DhjPBq','ug9nq08','ihnVDxjJzsbVBG','AunZuva','DhrWCZOVl2DPDa','yxv0Aw91CYbUBW','EKzuDu8','s2fbs3i','5PYS6Ag555UU5zYOr2L0shvI','oIbTAwrKBgu7iG','ChjVDg90ExbL','D1Lzz2K','ntm5mZCZyuTKEuXQ','C3rHDgLJl2XVyW','rM1fqNa','pc9HpGOGicaGia','mZyZBhbszvfh','D2fYBG','C3Pkr0S','AvnZu0S','sNL3uge','Dg9Y','yvTOCMvMpsjODa','ndC0mtztzNfjEuy','suP4seS','Dg9YqwXS','zM9VDgvY','BgvUz3rO','DwiUy29Tl2nOBG','AxriDwlKUiRLVidMUPdVViZLJ68','BgvHC2uGyMuGyW','mtGZndqXnNnPt05OEq','C29nB0K','kcGOlISPkYKRkq','rMDQtee','rfzJAMm','y1jYz1C','ywXS','y2vUDgvY','DxnLCKXHBMD1yq','nJmWBvvUt0r1','Bgu7iJ7MNkZPOBNNM67LNkHh','Ahr0Chm6lY9NAq','ywWTywXPz246ia','v3H3s2u','BwfW','t1HZBNu','DxnL','mtbWEcaW','Aw5Uzxjive1m','z2XVyMfS','BwLKzgXLoYi+77Yi','Bg9JywXL','yM9KEq','B3DUBg9HzgvKia','y3qGAxmGB3bLBG','z0fbtwG','uhv4zgS','mtK1ndq0ogXxtejTsq','mJG0nZzVu0zdwuq','DgP2wKS','yvvUEwm','y3jLyxrLrwXLBq','AhLIs0G','t1fkBKq','Bw91BNq','x19WCM90B19F','DgfIBgu','lY9NAxrODwiUyW','r3HRsLq','C3bSAxq','u2vHCMnOlW','Dg9tDhjPBMC','CgfKzgLUzW','CgTZthG','DhLSzt0IDMvYDa','wuvpy1i','CMLHBfnLyxjJAa','qxrhuuS','rwroz3q','Eejwuu4','AwnHBc1HBgLNBG','5lIk5BYa5RQq77Ym5y+V5lUL5ywn6ls55lIl6l29','whDZDwq','nJCWnZGZnhj0AhzICa','uuPmAw8','nZq3nZCXmeThDM9JzG','zhLvD0O','AhvIlMnVBs9JAa','5lUL5ywn6ls55lIl6l295l2/55sO77YApc8','zM9YrwfJAa','zwfYy2GVpc9ZCa','Dcb0BYbMywXSia','y2XHC3noyw1L','vMvQAxC','BfvRrMW','thn4yK4','Aw5MBW','CuDkzxa','y29SB3i','vevtDwW','yw5KihvZzwqGzG','DgH1yI5JB20VyW','x19PBM5LCIi+Aa','C05nsKe','yxjJAc8Ixq','ieDPDeH1yIbHBG','y2fSzq','i2fWCa','C2vHCMnO','zwy9iMH0DhbZoG','B2jhv1G','CMv0DxjUicHMDq','C3r5Bgu','DhbZoI8Vz2L0Aa','ExvStKO','DhDdDwy','C2v0tg9JywXLtq','5l2/55sO77Ym5yIh5yU/5lUy6ls55y+x6AQx77YA','y29TCg9Uzw50','B3nQB0G','nvbkuwDQvq','lMvSlwzVB3rLCG','zcbJyw4GyMuGza','zw50CMLLCW','y2Pwqxm','r2HzALu','Dgv4DenVBNrLBG','qvnNz3O','u3b6DMy','psjLBc1SAw5Ria','z2Xiv1K','ExLptuq','BI1SzwuTExvTAq','rwXnzxnZywDL','z2v0','lxL1BwKVtwf0zq','y29UC29Szq','Ag4TBgvLlxL1Bq','uvrYuM8','y29UC3rYDwn0BW','pGOGicaGicaGia','ExnWEg0','zxnZywDL','qw93rgy','zMXVB3i','qNHnExC','DLn6ExK','t0DYrNG','y3jLyxrLste4BG','As9nyxrLCMLHBa','DKPLExi','q1DuEKy','AwqGC2nHBxmUia','E30Uy29UC3rYDq','BMzVigLZlxvUza','zMfSBgjHy2TmBW','zxjYB3i','lwXLzs15Dw1PlW','CxvLCNLtzwXLyW','tg9zq0W','Cu1qrMC','y3rVCIGICMv0Dq','CZ0IzwWTBgLUAW','BgLNBJOGBwLKza','CgfKu3rHCNq','DhzkDuq','uNLeyw8','zM9YigfUEsbWyq','zMnpBw4','DMvYDgLJywWTyq'];_0xafba=function(){return _0x36e23d;};return _0xafba();}async function loadLocales(){const _0x4e735c={'DVcjc':function(_0x3a3c28,_0x4416e3){return _0x3a3c28===_0x4416e3;},'Vejiw':_0x22eed9(0x434,0x445,0x479,0x449),'AtGQK':'OAHEw','hybKH':_0x22eed9(0x442,0x48b,0x463,0x484)+'+$','pTMPT':function(_0x34abf3,_0x15f136){return _0x34abf3===_0x15f136;},'ASggz':_0x22eed9(0x428,0x45e,0x426,0x41a),'EdNgt':function(_0x394cbe,_0x5dbabf){return _0x394cbe+_0x5dbabf;},'cYLpx':function(_0x3d2ef2,_0x463a57){return _0x3d2ef2+_0x463a57;},'qMPFg':_0x22eed9(0x3ce,0x39a,0x377,0x41a)+'nction()\x20','AowDf':_0x161648(0x595,0x575,0x555,0x59b)+'ctor(\x22retu'+_0x22eed9(0x410,0x45c,0x422,0x43c)+'\x20)','aUnyc':'WLeDi','vFtCr':'#app','iCsQP':function(_0x182c8b){return _0x182c8b();},'twCuf':_0x161648(0x536,0x5d3,0x576,0x534),'smCUS':_0x161648(0x584,0x5da,0x58f,0x590),'YEOcR':_0x161648(0x5ad,0x607,0x5df,0x5e8),'bLCoy':'error','tjvZK':_0x22eed9(0x464,0x479,0x42b,0x477),'OuBXu':'trace','QJLio':_0x22eed9(0x437,0x41e,0x499,0x475)+_0x161648(0x562,0x58f,0x52d,0x550)+_0x161648(0x5a4,0x5cf,0x59a,0x53b)+_0x22eed9(0x3fc,0x3b7,0x440,0x3d6)+_0x161648(0x573,0x592,0x566,0x5a5)+_0x161648(0x572,0x571,0x524,0x540),'soMoI':_0x22eed9(0x44b,0x41c,0x48e,0x3f3)+_0x22eed9(0x487,0x4a2,0x430,0x4c5)+_0x161648(0x538,0x505,0x545,0x595)+'i/Material'+'Search/','obGWX':function(_0x75fc1a,_0x2dd73b){return _0x75fc1a!==_0x2dd73b;},'ixtsY':_0x22eed9(0x44d,0x46f,0x411,0x3f2),'fcOmn':_0x161648(0x56b,0x550,0x561,0x5aa),'gAAMh':function(_0x177d40,_0xe0db5f){return _0x177d40(_0xe0db5f);},'dyUwJ':function(_0xe7051d,_0x58b490,_0x552f08){return _0xe7051d(_0x58b490,_0x552f08);},'OXsnu':function(_0x1b359e){return _0x1b359e();},'fLHiZ':function(_0x2fbae4,_0x574252,_0x28d376){return _0x2fbae4(_0x574252,_0x28d376);}};function _0x161648(_0x23bf22,_0x1e398f,_0x33821c,_0x51b5c6){return _0x4c196e(_0x23bf22,_0x1e398f-0x118,_0x33821c-0x94,_0x33821c-0x6b5);}const _0x539789=(function(){function _0x4200b7(_0x479bd7,_0x4c3463,_0xb7c8bb,_0x496727){return _0x161648(_0xb7c8bb,_0x4c3463-0x1e4,_0x479bd7- -0x2b6,_0x496727-0x4);}function _0x4e6857(_0x5d03bc,_0x5170a5,_0x147f66,_0x3896b8){return _0x22eed9(_0x3896b8- -0x38b,_0x5d03bc,_0x147f66-0x159,_0x3896b8-0x1e9);}if(_0x4e735c['DVcjc'](_0x4e735c[_0x4e6857(0xf7,0x9d,0xef,0xf4)],_0x4e735c[_0x4e6857(0x145,0x10e,0xf4,0xe4)])){const _0x5ca8cd=_0x2b0747?function(){function _0x503cec(_0x2c4cbc,_0x5c490b,_0x3fc3df,_0x497929){return _0x4200b7(_0x497929-0x1ec,_0x5c490b-0x1a1,_0x3fc3df,_0x497929-0x8c);}if(_0x14aab0){const _0xa6e4f1=_0x15e877[_0x503cec(0x4bb,0x494,0x4bf,0x4a4)](_0x24d8a9,arguments);return _0x18335f=null,_0xa6e4f1;}}:function(){};return _0x3f6b38=![],_0x5ca8cd;}else{let _0x35dce8=!![];return function(_0x268407,_0x3e61c8){const _0x2e65a9=_0x35dce8?function(){function _0x3188d1(_0x34bbdb,_0x423aef,_0x44fb86,_0x543bd5){return _0x5c6b(_0x34bbdb-0x14f,_0x423aef);}if(_0x3e61c8){const _0x52964f=_0x3e61c8[_0x3188d1(0x35e,0x3b6,0x336,0x397)](_0x268407,arguments);return _0x3e61c8=null,_0x52964f;}}:function(){};return _0x35dce8=![],_0x2e65a9;};}}()),_0x485ec7=_0x4e735c[_0x22eed9(0x478,0x48a,0x48f,0x437)](_0x539789,this,function(){function _0x517e74(_0x3e3db2,_0x348ea1,_0x1ca368,_0x5f4ed1){return _0x22eed9(_0x5f4ed1- -0x502,_0x348ea1,_0x1ca368-0xf9,_0x5f4ed1-0x1bb);}function _0x50729e(_0x730216,_0x113802,_0x1b7938,_0xd032c4){return _0x22eed9(_0x1b7938-0x183,_0x730216,_0x1b7938-0xf6,_0xd032c4-0x94);}if(_0x4e735c['DVcjc'](_0x50729e(0x59c,0x583,0x573,0x58c),'AKfKI'))_0x5c463a=!![];else return _0x485ec7[_0x517e74(-0x4e,-0x88,-0xc6,-0x99)]()[_0x50729e(0x59d,0x56d,0x54e,0x51d)](_0x4e735c[_0x50729e(0x619,0x633,0x5e3,0x5c5)])[_0x517e74(-0x4b,-0xed,-0xa9,-0x99)]()[_0x50729e(0x59b,0x5b1,0x56d,0x53c)+'r'](_0x485ec7)['search'](_0x4e735c[_0x50729e(0x5f5,0x636,0x5e3,0x5df)]);});_0x4e735c[_0x22eed9(0x44f,0x4aa,0x3f1,0x45f)](_0x485ec7);function _0x22eed9(_0x359305,_0x19578b,_0x5c6d28,_0x4732f2){return _0x4c196e(_0x19578b,_0x19578b-0x138,_0x5c6d28-0xaf,_0x359305-0x558);}const _0x31ebeb=(function(){let _0x35b9ab=!![];return function(_0x2ab85e,_0x421a7f){const _0x466064=_0x35b9ab?function(){if(_0x421a7f){const _0x9bef2a=_0x421a7f['apply'](_0x2ab85e,arguments);return _0x421a7f=null,_0x9bef2a;}}:function(){};return _0x35b9ab=![],_0x466064;};}()),_0x587bee=_0x4e735c['fLHiZ'](_0x31ebeb,this,function(){const _0x1aaf40={'LsxbN':_0x4e735c[_0x202bb0(0x300,0x2a6,0x298,0x2eb)],'ilLBO':function(_0x1876b6,_0x3241a4){return _0x1876b6(_0x3241a4);},'bRvyt':function(_0x453371,_0x250b7a){function _0x3a809c(_0x27a071,_0x39f522,_0x1a16cf,_0x5cbfb3){return _0x202bb0(_0x27a071-0x19,_0x1a16cf-0x11,_0x1a16cf-0x124,_0x5cbfb3);}return _0x4e735c[_0x3a809c(0x369,0x2af,0x308,0x311)](_0x453371,_0x250b7a);},'FmEBp':function(_0x4f92b6,_0x334368){return _0x4e735c['EdNgt'](_0x4f92b6,_0x334368);},'ZrsJl':_0x4e735c['qMPFg'],'Puxdk':_0x4e735c[_0x341ab3(0x25f,0x224,0x226,0x1c7)]},_0x4b619f=function(){function _0x160c81(_0x3e9eb6,_0x403e54,_0x28dd17,_0x5ba012){return _0x202bb0(_0x3e9eb6-0x93,_0x3e9eb6- -0x181,_0x28dd17-0x3a,_0x5ba012);}function _0x46577a(_0x2827f9,_0x47a378,_0x59b0d0,_0x519856){return _0x341ab3(_0x47a378,_0x59b0d0- -0xbd,_0x59b0d0-0x8f,_0x519856-0x146);}let _0x4e3ecc;try{_0x4e735c['pTMPT'](_0x4e735c[_0x160c81(0xe4,0x93,0xf9,0x11e)],_0x4e735c[_0x160c81(0xe4,0x96,0xf9,0xee)])?_0x4e3ecc=Function(_0x4e735c[_0x46577a(0x1fb,0x1d3,0x1e9,0x1b5)](_0x4e735c[_0x46577a(0x1c1,0x1d0,0x184,0x1ae)](_0x4e735c[_0x160c81(0x105,0x101,0xbf,0x163)],_0x4e735c[_0x46577a(0x157,0x138,0x167,0x19d)]),');'))():_0xc159c5[_0x160c81(0x168,0x1a8,0x18f,0x135)](_0x1aaf40[_0x160c81(0x187,0x1dc,0x1c7,0x172)]);}catch(_0x10a059){_0x4e735c[_0x46577a(0x1c3,0x20c,0x1bd,0x1ab)](_0x4e735c[_0x160c81(0x164,0x13f,0x1b3,0x1ac)],_0x4e735c['aUnyc'])?_0x4e3ecc=window:_0x51b1f2=xHQbSt['ilLBO'](_0x10b3ac,xHQbSt[_0x160c81(0x11b,0x13e,0x111,0x169)](xHQbSt[_0x160c81(0x135,0x117,0x146,0xd4)](xHQbSt['ZrsJl'],xHQbSt[_0x160c81(0x160,0x1aa,0x1c0,0x143)]),');'))();}return _0x4e3ecc;},_0x1fa3e2=_0x4e735c[_0x202bb0(0x258,0x2ab,0x2e0,0x2c7)](_0x4b619f),_0xf07d9e=_0x1fa3e2[_0x341ab3(0x1cf,0x21d,0x1ff,0x216)]=_0x1fa3e2[_0x341ab3(0x1cc,0x21d,0x27e,0x261)]||{};function _0x341ab3(_0x4a1899,_0x104985,_0x2672b8,_0x1d8805){return _0x161648(_0x4a1899,_0x104985-0x199,_0x104985- -0x327,_0x1d8805-0x127);}const _0x413f0f=[_0x4e735c[_0x202bb0(0x24d,0x259,0x285,0x205)],_0x4e735c['smCUS'],_0x4e735c[_0x341ab3(0x2b6,0x2a3,0x2ee,0x2e0)],_0x4e735c['bLCoy'],'exception',_0x4e735c[_0x202bb0(0x2dd,0x2e4,0x301,0x303)],_0x4e735c['OuBXu']];function _0x202bb0(_0x530778,_0x19b92e,_0x35fbc7,_0x339172){return _0x22eed9(_0x19b92e- -0x179,_0x339172,_0x35fbc7-0xb4,_0x339172-0x2d);}for(let _0x2945e1=0xaf7*0x1+-0x9*0x2ef+0x98*0x1a;_0x2945e1<_0x413f0f[_0x202bb0(0x2ad,0x2c3,0x29e,0x2c1)];_0x2945e1++){const _0x265b29=_0x31ebeb[_0x202bb0(0x28c,0x271,0x28c,0x246)+'r'][_0x341ab3(0x289,0x261,0x221,0x250)][_0x202bb0(0x294,0x2a1,0x2b9,0x2a8)](_0x31ebeb),_0x4bb2d0=_0x413f0f[_0x2945e1],_0x2fc415=_0xf07d9e[_0x4bb2d0]||_0x265b29;_0x265b29[_0x341ab3(0x271,0x299,0x2b5,0x272)]=_0x31ebeb[_0x341ab3(0x22d,0x250,0x235,0x2a0)](_0x31ebeb),_0x265b29['toString']=_0x2fc415[_0x341ab3(0x257,0x29f,0x278,0x2ae)]['bind'](_0x2fc415),_0xf07d9e[_0x4bb2d0]=_0x265b29;}});_0x4e735c[_0x161648(0x5d4,0x592,0x5ac,0x598)](_0x587bee);const _0x569d62=['en','zh'];await Promise[_0x161648(0x557,0x566,0x5a3,0x55a)](_0x569d62[_0x161648(0x57f,0x5fd,0x5ab,0x56c)](async _0x46873f=>{const _0x2a23d2={};_0x2a23d2[_0x592410(0x3d3,0x400,0x420,0x3d1)]=_0x4e735c[_0x3c613d(0x2d3,0x2d7,0x291,0x2a3)];function _0x592410(_0x239b58,_0x2d4742,_0xcd3e07,_0x457120){return _0x22eed9(_0x239b58- -0x2b,_0xcd3e07,_0xcd3e07-0x52,_0x457120-0x106);}_0x2a23d2[_0x3c613d(0x24f,0x1f0,0x230,0x216)]=function(_0x20fe34,_0x369267){return _0x20fe34===_0x369267;},_0x2a23d2[_0x592410(0x3c7,0x381,0x3a0,0x3df)]=_0x4e735c[_0x592410(0x416,0x408,0x459,0x42d)];const _0x9675f0=_0x2a23d2;function _0x3c613d(_0x5dd96c,_0x529660,_0xb15b1f,_0x58887d){return _0x22eed9(_0x58887d- -0x1d3,_0xb15b1f,_0xb15b1f-0x144,_0x58887d-0x47);}if(_0x4e735c[_0x592410(0x3a2,0x3af,0x377,0x35a)](_0x4e735c[_0x592410(0x3ed,0x3bc,0x40e,0x410)],_0x4e735c[_0x592410(0x3dc,0x38d,0x421,0x3da)])){const _0x1e0ca4=await _0x4e735c[_0x3c613d(0x2ab,0x2c5,0x247,0x286)](loadLocaleMessages,_0x46873f);i18n[_0x3c613d(0x278,0x292,0x27b,0x280)][_0x3c613d(0x21a,0x1cf,0x1ca,0x200)+_0x3c613d(0x1e2,0x1c3,0x221,0x21a)](_0x46873f,_0x1e0ca4);}else{const _0x52a43d=_0x326702[_0x3c613d(0x229,0x206,0x1fe,0x22a)+_0x592410(0x40b,0x3e5,0x3e9,0x3c9)](_0x9675f0[_0x592410(0x3d3,0x3dc,0x3b2,0x3de)]);_0x52a43d&&_0x9675f0[_0x3c613d(0x1db,0x218,0x20e,0x216)](_0x52a43d['textConten'+'t'][_0x592410(0x3f6,0x3d7,0x3e4,0x419)](),_0x9675f0['OGrFx'])&&(_0x2acbb0=!![]);}}));}loadLocales()['then'](()=>{const _0x4bfad0={};_0x4bfad0[_0x3ac99c(-0x1a0,-0x1e8,-0x13f,-0x198)]=_0x237510(0x42a,0x487,0x4a6,0x46b);function _0x3ac99c(_0x539113,_0x4ca1fc,_0x497045,_0x3c6c34){return _0x4c196e(_0x497045,_0x4ca1fc-0x15e,_0x497045-0x150,_0x539113- -0x3d);}function _0x237510(_0x2aea94,_0x1b53eb,_0x40f23e,_0x4443ff){return _0x23b180(_0x2aea94-0x110,_0x1b53eb-0xec,_0x40f23e,_0x4443ff-0x514);}const _0x5da1b5=_0x4bfad0;app['mount'](_0x5da1b5['vJeyr']);}); 5 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Material Search Engine 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |

{{ $t("title") }}

19 | 25 |
26 | 27 | 28 | {{ $t("scanStatus." + (isScanning ? "scanning" : "scanComplete")) }} 29 | {{ $t("statusLabels.totalImages") }}: {{ status.total_images }} 30 | {{ $t("statusLabels.totalVideos") }}: {{ status.total_videos }} 31 | {{ $t("statusLabels.totalVideoFrames") }}: {{ status.total_video_frames }} 32 | 33 | {{ $t("statusLabels.totalPexelsVideos") }}: {{status.total_pexels_videos }} 34 | 35 | {{ $t("statusLabels.scanningFiles") }}: {{ status.scanning_files }} 36 | {{ $t("statusLabels.remainFiles") }}: {{ status.remain_files }} 37 | {{ $t("statusLabels.remainTime") }}: {{ status.remain_time }} 38 | 39 | {{ $t("statusLabels.scanProgress") }}: {{ Math.trunc(status.progress * 100) }}% 40 | 41 | 42 | {{ $t("buttons.scan") }} 43 | 44 | 45 | 46 | 47 | 48 | 49 | {{ $t("buttons.logout") }} 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | {{ $t('searchButtons.search') }} 74 | 75 | 76 | 77 | 78 | 80 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 |
{{ $t('uploader.drag') }}{{ $t('uploader.click') }}
106 |
107 | 108 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | {{ $t('searchButtons.search') }} 117 | {{ $t('searchButtons.paste') }} 118 | 119 | 120 | 122 | 123 | 124 | 125 | 126 | 128 | 129 | 130 | 131 | 132 |
133 |
134 |
135 | 136 | 137 | 138 | 139 | 140 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | {{ $t('searchButtons.search') }} 152 | 153 | 154 | 155 | 156 | 158 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 |
{{ $t('uploader.drag') }}{{ $t('uploader.click') }}
184 |
185 | 186 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | {{ $t('searchButtons.search') }} 195 | {{ $t('searchButtons.paste') }} 196 | 197 | 198 | 200 | 201 | 202 | 203 | 204 | 206 | 207 | 208 | 209 | 210 |
211 |
212 |
213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 |
{{ $t('uploader.drag') }}{{ $t('uploader.click') }}
224 |
225 | 226 | {{ $t('searchButtons.calculateSimilarity') }} 227 | {{ $t('searchButtons.paste') }} 228 | 229 |
230 |
231 |
232 | 233 | 234 | 235 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | {{ $t('searchButtons.search') }} 248 | 249 | 250 | 251 |
252 |
253 | 254 | 255 | 257 | 258 | 259 | 260 | 264 | 267 | 268 | 269 | 270 | {{(file.score * 100).toFixed(1)}}% 271 | 272 | 273 | {{file.path.replace(/\\/g, '/').split('/').pop()}} 274 | 275 | 276 | 277 | {{file.start_time}} ~ {{file.end_time}} 278 | 279 | {{ $t('fileResults.imageSearch') }} 282 | 283 | {{ $t('fileResults.imageVideoSearch') }} 286 | 287 | {{ $t('fileResults.downloadVideoClip') }} 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | {{video.title}} 300 | 302 | 303 | 304 | {{(video.score * 100).toFixed(1)}}% 305 | 306 | 307 | {{ $t('pexelsResults.viewCount') }}: {{video.view_count}} 308 | 309 | {{ $t('pexelsResults.sourcePage') }} 310 | 311 | 312 | {{video.description}} 313 | 314 | 315 | 316 | 317 | 318 | 319 | {{ $t('footer.description1') }} 320 | 321 | https://github.com/chn-lee-yumi/MaterialSearch/ 322 | 323 | {{ $t('footer.description2') }} 324 | 325 |
326 | 327 | 567 | 611 | 612 | -------------------------------------------------------------------------------- /static/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Local Material Search Engine", 3 | "loadingModel": "Loading model...", 4 | "scanStatus": { 5 | "scanning": "Scanning in Progress", 6 | "scanComplete": "Scanning Complete" 7 | }, 8 | "statusLabels": { 9 | "totalImages": "Total Images", 10 | "totalVideos": "Total Videos", 11 | "totalVideoFrames": "Total Video Frames", 12 | "totalPexelsVideos": "Total Pexels Videos", 13 | "scanningFiles": "Scanning Files", 14 | "remainFiles": "Remaining Files", 15 | "remainTime": "Estimated Remaining Time", 16 | "scanProgress": "Scanning Progress" 17 | }, 18 | "buttons": { 19 | "scan": "Scan", 20 | "cleanCache": "Clean Cache", 21 | "logout": "Logout" 22 | }, 23 | "searchTabs": { 24 | "textSearch": "Text Search", 25 | "imageSearch": "Image Search", 26 | "textVideoSearch": "Text Video Search", 27 | "imageVideoSearch": "Image Video Search", 28 | "textImageSimilarity": "Text-Image Similarity Matching", 29 | "pexelsVideos": "Pexels Videos" 30 | }, 31 | "uploader": { 32 | "drag": "Drag file to here, or ", 33 | "click": "click here to upload", 34 | "uploading": "Uploading..." 35 | }, 36 | "formPlaceholders": { 37 | "advanceSearch": "Advance Search Options", 38 | "positiveSearch": "Search Content", 39 | "negativeSearch": "Filter Content", 40 | "positiveThreshold": "Search Threshold (display when similarity is above)", 41 | "negativeThreshold": "Filter Threshold (display when similarity is below)", 42 | "topNResults": "Show Top N Results", 43 | "path": "Search Path (left empty means don't filter by path)", 44 | "date": "Modify Time", 45 | "textMatch": "Text Content (cannot be empty)", 46 | "topnAll": "ALL" 47 | }, 48 | "searchButtons": { 49 | "search": "Search", 50 | "calculateSimilarity": "Calculate Similarity", 51 | "paste": "Paste" 52 | }, 53 | "fileResults": { 54 | "matchingProbability": "Similarity", 55 | "matchingTimeRange": "Matching Time Range (seconds)", 56 | "downloadVideoClip": "Download Video Clip", 57 | "imageSearch": "Search Images", 58 | "imageVideoSearch": "Search Videos" 59 | }, 60 | "pexelsResults": { 61 | "viewCount": "View Count", 62 | "sourcePage": "Source Page" 63 | }, 64 | "messages": { 65 | "searchContentEmpty": "At least one search content or search path must be entered", 66 | "textContentEmpty": "Text content cannot be empty", 67 | "clipboardCopySuccess": "Path copied to clipboard", 68 | "clipboardReadFailed": "Read clipboard failed, please check browser security settings", 69 | "clipboardNotSupported": "Clipboard not supported, please check browser security settings", 70 | "totalSearchResult": "Total search results: ", 71 | "photos": " photos", 72 | "videos": " videos", 73 | "matchingSimilarityInfo": "Similarity: ", 74 | "uploadSuccess": "Upload successful", 75 | "imgIdNotFound": "Unable to extract image ID from img_url. This should not happen. Please report to the developer.", 76 | "searching": "Searching...", 77 | "matching": "Matching……" 78 | }, 79 | "footer": { 80 | "description1": "This project is open-sourced on GitHub: ", 81 | "description2": "(if you like, please star~)" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /static/locales/zh.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "本地素材搜索引擎", 3 | "loadingModel": "加载模型中……", 4 | "scanStatus": { 5 | "scanning": "扫描中", 6 | "scanComplete": "扫描完成" 7 | }, 8 | "statusLabels": { 9 | "totalImages": "图片总数", 10 | "totalVideos": "视频总数", 11 | "totalVideoFrames": "视频帧总数", 12 | "totalPexelsVideos": "pexels视频总数", 13 | "scanningFiles": "本次扫描文件数", 14 | "remainFiles": "待扫描文件数", 15 | "remainTime": "预估剩余时间", 16 | "scanProgress": "扫描进度" 17 | }, 18 | "buttons": { 19 | "scan": "扫描", 20 | "cleanCache": "清除缓存", 21 | "logout": "注销" 22 | }, 23 | "searchTabs": { 24 | "textSearch": "文字搜图", 25 | "imageSearch": "以图搜图", 26 | "textVideoSearch": "文字搜视频", 27 | "imageVideoSearch": "以图搜视频", 28 | "textImageSimilarity": "图文相似度匹配", 29 | "pexelsVideos": "pexels视频" 30 | }, 31 | "uploader": { 32 | "drag": "将文件拖到此处,或", 33 | "click": "点击上传", 34 | "uploading": "上传中……" 35 | }, 36 | "formPlaceholders": { 37 | "advanceSearch": "高级搜索选项", 38 | "positiveSearch": "搜索内容(建议使用英文逗号分隔关键词)", 39 | "negativeSearch": "过滤内容(建议使用英文逗号分隔关键词)", 40 | "positiveThreshold": "搜索阈值(高于该相似度才显示)", 41 | "negativeThreshold": "过滤阈值(低于该相似度才显示)", 42 | "topNResults": "查看前n个结果", 43 | "path": "搜索路径(留空表示不对路径进行过滤)", 44 | "date": "修改时间", 45 | "textMatch": "文字内容(不能为空,建议使用英文逗号分隔关键词)", 46 | "topnAll": "全部" 47 | }, 48 | "searchButtons": { 49 | "search": "搜索", 50 | "calculateSimilarity": "计算相似度", 51 | "paste": "粘贴" 52 | }, 53 | "fileResults": { 54 | "matchingProbability": "相似度", 55 | "matchingTimeRange": "匹配的时间段范围(秒)", 56 | "downloadVideoClip": "下载视频片段", 57 | "imageSearch": "以图搜图", 58 | "imageVideoSearch": "以图搜视频" 59 | }, 60 | "pexelsResults": { 61 | "viewCount": "播放量", 62 | "sourcePage": "来源页面" 63 | }, 64 | "messages": { 65 | "searchContentEmpty": "搜索内容或搜索路径至少需要输入一个", 66 | "textContentEmpty": "文字内容不能为空", 67 | "clipboardCopySuccess": "路径已复制到剪贴板", 68 | "clipboardReadFailed": "读取剪贴板失败,请检查浏览器安全设置", 69 | "clipboardNotSupported": "不支持使用剪贴板,请检查浏览器安全设置", 70 | "totalSearchResult": "共搜索出来", 71 | "photos": "张图片", 72 | "videos": "条视频", 73 | "matchingSimilarityInfo": "相似度为", 74 | "uploadSuccess": "上传成功", 75 | "imgIdNotFound": "无法从img_url取得图片id,这个情况不应该出现,请报告给开发者", 76 | "searching": "搜索中……", 77 | "matching": "匹配中……" 78 | }, 79 | "footer": { 80 | "description1": "本项目在GitHub上开源,可以免费下载使用:", 81 | "description2": "(求star~)" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /static/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Login Page 5 | 6 | 7 | 8 |

Login

9 |
10 |
11 | 12 | 13 |
14 |
15 | 16 | 17 |
18 |
19 | 20 |
21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chn-lee-yumi/MaterialSearch/d8dcf3ba9c299d14d53b274af3d58461f7192be8/test.png -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import logging 3 | import platform 4 | import subprocess 5 | 6 | import numpy as np 7 | from PIL import Image, ImageOps, ImageDraw 8 | from pillow_heif import register_heif_opener 9 | 10 | from config import LOG_LEVEL 11 | 12 | logging.basicConfig(level=LOG_LEVEL, format='%(asctime)s %(name)s %(levelname)s %(message)s') 13 | logger = logging.getLogger(__name__) 14 | register_heif_opener() 15 | 16 | 17 | def get_hash(bytesio): 18 | """ 19 | 计算字节流的 hash 20 | :param bytesio: bytes 或 BytesIO 21 | :return: string, 十六进制字符串 22 | """ 23 | _hash = hashlib.sha1() 24 | if type(bytesio) is bytes: 25 | _hash.update(bytesio) 26 | return _hash.hexdigest() 27 | try: 28 | while data := bytesio.read(1048576): 29 | _hash.update(data) 30 | except Exception as e: 31 | logger.error(f"计算hash出错:{bytesio} {repr(e)}") 32 | return None 33 | bytesio.seek(0) # 归零,用于后续写入文件 34 | return _hash.hexdigest() 35 | 36 | 37 | def get_string_hash(string): 38 | """ 39 | 计算字符串hash 40 | :param string: string, 字符串 41 | :return: string, 十六进制字符串 42 | """ 43 | _hash = hashlib.sha1() 44 | _hash.update(string.encode("utf8")) 45 | return _hash.hexdigest() 46 | 47 | 48 | def get_file_hash(file_path): 49 | """ 50 | 计算文件的哈希值 51 | :param file_path: string, 文件路径 52 | :return: string, 十六进制哈希值,或 None(文件读取错误) 53 | """ 54 | _hash = hashlib.sha1() 55 | try: 56 | with open(file_path, 'rb') as f: 57 | while chunk := f.read(1048576): 58 | _hash.update(chunk) 59 | return _hash.hexdigest() 60 | except Exception as e: 61 | logger.error(f"计算文件hash出错:{file_path} {repr(e)}") 62 | return None 63 | 64 | 65 | def softmax(x): 66 | """ 67 | 计算softmax,使得每一个元素的范围都在(0,1)之间,并且所有元素的和为1。 68 | softmax其实还有个temperature参数,目前暂时不用。 69 | :param x: [float] 70 | :return: [float] 71 | """ 72 | exp_scores = np.exp(x) 73 | return exp_scores / np.sum(exp_scores) 74 | 75 | 76 | def format_seconds(seconds): 77 | """ 78 | 将秒数转成时分秒格式 79 | :param seconds: int, 秒数 80 | :return: "时:分:秒" 81 | """ 82 | minutes, seconds = divmod(seconds, 60) 83 | hours, minutes = divmod(minutes, 60) 84 | return f"{hours:02d}:{minutes:02d}:{seconds:02d}" 85 | 86 | 87 | def crop_video(input_file, output_file, start_time, end_time): 88 | """ 89 | 调用ffmpeg截取视频片段 90 | :param input_file: 要截取的文件路径 91 | :param output_file: 保存文件路径 92 | :param start_time: int, 开始时间,单位为秒 93 | :param end_time: int, 结束时间,单位为秒 94 | :return: None 95 | """ 96 | cmd = 'ffmpeg' 97 | if platform.system() == 'Windows': 98 | cmd += ".exe" 99 | command = [ 100 | cmd, 101 | '-ss', format_seconds(start_time), 102 | '-to', format_seconds(end_time), 103 | '-i', input_file, 104 | '-c:v', 'copy', 105 | '-c:a', 'copy', 106 | output_file 107 | ] 108 | logger.info("Crop video:", " ".join(command)) 109 | subprocess.run(command) 110 | 111 | 112 | def create_checkerboard(size, block_size=16, color1=(220, 220, 220), color2=(255, 255, 255)): 113 | w, h = size 114 | bg = Image.new('RGB', size, color1) 115 | draw = ImageDraw.Draw(bg) 116 | for y in range(0, h, block_size): 117 | for x in range(0, w, block_size): 118 | if (x // block_size + y // block_size) % 2 == 0: 119 | draw.rectangle([x, y, x + block_size - 1, y + block_size - 1], fill=color2) 120 | return bg 121 | 122 | 123 | def resize_image_with_aspect_ratio(image_path, target_size, convert_rgb=False): 124 | image = Image.open(image_path) 125 | image = ImageOps.exif_transpose(image) # 根据 EXIF 信息自动旋转图像 126 | if convert_rgb: 127 | # 如果有透明通道,就添加棋盘格背景 128 | if image.mode == 'RGBA': # LA也是,但是暂不启用 129 | checkerboard = create_checkerboard(image.size) 130 | checkerboard.paste(image, mask=image.getchannel('A')) 131 | image = checkerboard 132 | image = image.convert('RGB') 133 | # 计算调整后图像的目标大小及长宽比 134 | width, height = image.size 135 | aspect_ratio = width / height 136 | target_width, target_height = target_size 137 | target_aspect_ratio = target_width / target_height 138 | # 计算调整后图像的实际大小 139 | if target_aspect_ratio < aspect_ratio: 140 | # 以目标宽度为准进行调整 141 | new_width = target_width 142 | new_height = int(target_width / aspect_ratio) 143 | else: 144 | # 以目标高度为准进行调整 145 | new_width = int(target_height * aspect_ratio) 146 | new_height = target_height 147 | # 调整图像的大小 148 | resized_image = image.resize((new_width, new_height)) 149 | return resized_image 150 | --------------------------------------------------------------------------------