├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── config.yml └── workflows │ ├── CI.yml │ ├── Create Comment.yml │ └── Issue Close Question.yml ├── .gitignore ├── LICENSE ├── data ├── color_en.csv ├── color_zh.csv ├── size_en.csv └── size_zh.csv ├── docs ├── CONTRIBUTING.md ├── README-EN.md └── README.md ├── images ├── test1.jpg ├── test1_output_background.jpg ├── test1_output_corrected.jpg ├── test1_output_resized.jpg ├── test1_output_sheet.jpg ├── test2.jpg ├── test2_output_sheet.jpg ├── test3.jpg ├── test3_output_sheet.jpg └── workflows.png ├── requirements.txt ├── run_en.bat ├── run_webui.bat ├── run_zh.bat ├── src ├── __init__.py ├── main.py ├── model │ └── .gitignore ├── tool │ ├── ConfigManager.py │ ├── ImageProcessor.py │ ├── ImageSegmentation.py │ ├── PhotoEntity.py │ ├── PhotoRequirements.py │ ├── PhotoSheetGenerator.py │ ├── YuNet.py │ ├── __init__.py │ ├── agpic.py │ ├── ext │ │ └── .gitignore │ └── yolov8_detector.py └── webui │ ├── app.py │ └── i18n │ ├── en.json │ └── zh.json └── tests ├── test_liying.py └── test_photo_sizes.py /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: 反馈与提交BUG / Feedback and submit bugs 3 | title: "[BUG] 标题简要描述 / Brief description of the title" 4 | labels: [ "bug" ] 5 | assignees: [ ] 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | 创建一个反馈报告以帮助我们改进 11 | 12 | Create a feedback report to help us improve 13 | - type: checkboxes 14 | id: checkboxes 15 | attributes: 16 | label: | 17 | 一些验证 18 | 19 | Some verifications 20 | description: | 21 | 在提交问题之前,请确保您完成以下操作 22 | 23 | Before submitting an issue, please ensure you have completed the following 24 | options: 25 | - label: | 26 | 是否查看 wiki、issues 后自己尝试解决 27 | 28 | Have you tried to resolve the issue by checking the wiki and existing issues? 29 | required: true 30 | - label: | 31 | 请 **确保** 您的问题能在 [releases](https://github.com/aoguai/LiYing/releases/latest) 发布的最新版本(包含测试版本)上复现 (如果不是请先更新到最新版本复现后再提交问题) 32 | 33 | Please **ensure** your issue can be reproduced on the latest version (including test versions) released in [releases](https://github.com/aoguai/LiYing/releases/latest) (if not, please update to the latest version and reproduce the issue before submitting it) 34 | required: true 35 | - label: | 36 | 搜索检查是否已经存在请求相同功能的问题/讨论,以避免重复创建问题 37 | 38 | Search to check if there are already issues/discussions requesting the same feature to avoid duplication 39 | required: true 40 | - label: | 41 | 确认知晓并同意维护者直接关闭不符合 issue 规范的问题 42 | 43 | Acknowledge and agree that maintainers can directly close issues that do not follow the issue guidelines 44 | required: true 45 | - label: | 46 | 确保提供下列BUG描述及其复现步骤, 否则我同意维护者直接关闭问题 47 | 48 | Ensure to provide the following bug description and reproduction steps, otherwise, I agree that maintainers can directly close the issue 49 | required: true 50 | - type: textarea 51 | id: bug-description 52 | attributes: 53 | label: | 54 | BUG 描述或反馈描述 55 | 56 | Bug description or feedback description 57 | description: | 58 | 请输入 BUG 描述或反馈描述及其复现步骤,请使用尽量准确的描述。 59 | 60 | Please enter the bug description or feedback description and its reproduction steps. Use as accurate a description as possible. 61 | validations: 62 | required: true 63 | - type: textarea 64 | id: expected-behavior 65 | attributes: 66 | label: | 67 | 预期的效果 68 | 69 | Expected behavior 70 | description: | 71 | 简明扼要地描述你原来希望的效果。 72 | 73 | Briefly describe what you originally expected to happen. 74 | validations: 75 | required: true 76 | - type: textarea 77 | id: screenshots 78 | attributes: 79 | label: | 80 | 截图 81 | 82 | Screenshots 83 | description: | 84 | 添加截图以帮助解释你的问题。 85 | 86 | Add screenshots to help explain your issue. 87 | validations: 88 | required: true 89 | - type: textarea 90 | id: system-info 91 | attributes: 92 | label: | 93 | 系统信息 94 | 95 | System information 96 | description: | 97 | 请说明您的操作系统: [例如.Windows]以及软件版本 [例如. V1.6] 98 | 99 | Please specify your operating system: [e.g., Windows] and software version [e.g., V1.6] 100 | validations: 101 | required: true 102 | - type: textarea 103 | id: additional-info 104 | attributes: 105 | label: | 106 | 额外的信息 107 | 108 | Additional information 109 | description: | 110 | 在此添加关于问题的任何其他背景、猜想、推断。 111 | 112 | Add any other context, assumptions, or inferences about the issue here. 113 | validations: 114 | required: false 115 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 讨论交流 / Discussions 4 | url: https://github.com/aoguai/LiYing/discussions 5 | about: Use GitHub discussions for message-board style questions and discussions. -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | build-windows-portable: 10 | runs-on: windows-latest 11 | env: 12 | PYTHON_VERSION: '3.8.10' 13 | EMBEDDABLE_URL: 'https://www.python.org/ftp/python/3.8.10/python-3.8.10-embed-amd64.zip' 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v2 18 | 19 | - name: Download embeddable Python 20 | run: | 21 | Invoke-WebRequest -Uri ${{ env.EMBEDDABLE_URL }} -OutFile python-embed.zip 22 | Expand-Archive -Path python-embed.zip -DestinationPath python-embed 23 | 24 | - name: Configure Python 25 | run: | 26 | $pythonPth = Get-Content 'python-embed\python38._pth' -Raw 27 | $pythonPth = $pythonPth -replace '#import site','import site' 28 | Set-Content -Path 'python-embed\python38._pth' -Value $pythonPth -NoNewline 29 | Invoke-WebRequest -Uri https://bootstrap.pypa.io/get-pip.py -OutFile python-embed\get-pip.py 30 | cd python-embed 31 | .\python.exe get-pip.py 32 | cd .. 33 | 34 | - name: Install dependencies 35 | run: | 36 | cd python-embed 37 | .\python.exe -m pip install "onnxruntime==1.14.0" 38 | .\python.exe -m pip install "orjson==3.10.7" 39 | .\python.exe -m pip install "gradio==4.41.0" 40 | .\python.exe -m pip install -r ..\requirements.txt 41 | cd .. 42 | 43 | - name: Prepare application files 44 | run: | 45 | New-Item -Path 'LiYing' -ItemType Directory 46 | 47 | Copy-Item -Path 'data' -Destination 'LiYing' -Recurse 48 | Copy-Item -Path 'src' -Destination 'LiYing' -Recurse 49 | Copy-Item -Path 'images' -Destination 'LiYing' -Recurse 50 | Copy-Item -Path 'requirements.txt' -Destination 'LiYing' 51 | Copy-Item -Path 'run_*.bat' -Destination 'LiYing' 52 | 53 | Copy-Item -Path 'python-embed' -Destination 'LiYing\python-embed' -Recurse 54 | 55 | - name: Create a temporary Python script for zipping 56 | run: | 57 | echo "import zipfile" > zip_package.py 58 | echo "import os" >> zip_package.py 59 | echo "zipf = zipfile.ZipFile('LiYing_Portable_Win_x64.zip', 'w', zipfile.ZIP_DEFLATED)" >> zip_package.py 60 | echo "for root, dirs, files in os.walk('LiYing'):" >> zip_package.py 61 | echo " for file in files:" >> zip_package.py 62 | echo " zipf.write(os.path.join(root, file), os.path.relpath(os.path.join(root, file), os.path.join('LiYing', '..')))" >> zip_package.py 63 | echo "zipf.close()" >> zip_package.py 64 | 65 | - name: Zip package using Python 66 | run: | 67 | python zip_package.py 68 | 69 | - name: Create Release 70 | id: create_release 71 | uses: actions/create-release@v1 72 | if: startsWith(github.ref, 'refs/tags/') 73 | env: 74 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 75 | with: 76 | tag_name: ${{ github.ref }} 77 | release_name: Release ${{ github.ref }} 78 | draft: false 79 | prerelease: false 80 | 81 | - name: Upload Release Asset 82 | uses: actions/upload-release-asset@v1 83 | if: startsWith(github.ref, 'refs/tags/') 84 | env: 85 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 86 | with: 87 | upload_url: ${{ steps.create_release.outputs.upload_url }} 88 | asset_path: ./LiYing_Portable_Win_x64.zip 89 | asset_name: LiYing_Win_x64_Portable.zip 90 | asset_content_type: application/zip 91 | -------------------------------------------------------------------------------- /.github/workflows/Create Comment.yml: -------------------------------------------------------------------------------- 1 | name: Create Comment 2 | 3 | on: 4 | issues: 5 | types: [labeled] 6 | 7 | jobs: 8 | create-comment: 9 | runs-on: ubuntu-latest 10 | if: github.event.label.name == 'need info' 11 | steps: 12 | - name: Create comment 13 | uses: actions-cool/issues-helper@v3 14 | with: 15 | actions: 'create-comment' 16 | token: ${{ secrets.GITHUB_TOKEN }} 17 | issue-number: ${{ github.event.issue.number }} 18 | body: | 19 | Hello ${{ github.event.issue.user.login }}. It seems that more information is needed for this issue. Please provide additional details. 20 | 21 | 你好 ${{ github.event.issue.user.login }}。看起来这个问题需要更多信息。请提供额外的细节。 22 | -------------------------------------------------------------------------------- /.github/workflows/Issue Close Question.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Issue Close Question 3 | 4 | on: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | issue-close-require: 13 | permissions: 14 | issues: write 15 | pull-requests: write 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: needs more info 19 | uses: actions-cool/issues-helper@v3 20 | with: 21 | actions: 'close-issues' 22 | labels: 'need info' 23 | inactive-day: 3 24 | body: | 25 | This issue has been closed automatically because it has not had recent activity for 3 days. If you have any questions, please comment here. 26 | 由于该 Issue 3天未收到回应,现已被自动关闭,若有任何问题,可评论回复。 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | .vscode/ 107 | 108 | .idea/ 109 | 110 | _myPython 111 | _myPython/* 112 | 113 | python-embed 114 | python-embed/* 115 | 116 | data/*.jpg 117 | data/*.jepg 118 | data/*.png 119 | 120 | src/tool/ext/*.exe 121 | src/model/*.onnx 122 | 123 | *.zip 124 | 125 | tests/output 126 | tests/output/* 127 | 128 | output 129 | output/* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /data/color_en.csv: -------------------------------------------------------------------------------- 1 | Name,R,G,B,Notes 2 | blue,98,139,206,Standard ID photo background color 3 | white,255,255,255,Used for some special ID photos 4 | red,215,69,50,Background color used in some countries 5 | light_blue,180,200,230,Softer background color 6 | dark_blue,0,71,171,Background color for some formal occasions 7 | gray,128,128,128,Neutral background color 8 | beige,245,245,220,Mild background color 9 | light_green,144,238,144,Fresh background color 10 | lavender,230,230,250,Soft background color 11 | sky_blue,135,206,235,Bright background color 12 | -------------------------------------------------------------------------------- /data/color_zh.csv: -------------------------------------------------------------------------------- 1 | Name,R,G,B,Notes 2 | 蓝色,98,139,206,标准证件照背景色 3 | 白色,255,255,255,常用于一些特殊证件照 4 | 红色,215,69,50,部分国家使用的背景色 5 | 浅蓝色,180,200,230,较为柔和的背景色 6 | 深蓝色,0,71,171,一些正式场合使用的背景色 7 | 灰色,128,128,128,中性背景色 8 | 米色,245,245,220,温和的背景色 9 | 浅绿色,144,238,144,清新的背景色 10 | 淡紫色,230,230,250,柔和的背景色 11 | 天蓝色,135,206,235,明亮的背景色 12 | -------------------------------------------------------------------------------- /data/size_en.csv: -------------------------------------------------------------------------------- 1 | Name,PrintWidth,PrintHeight,ElectronicWidth,ElectronicHeight,Resolution,FileFormat,FileSizeMin,FileSizeMax,Type,Notes 2 | Small One Inch,2.2,3.2,260,378,300,jpg,,,photo,2.2/2.54*300=260,3.2/2.54*300=378 3 | One Inch,2.5,3.5,295,413,300,jpg,,,photo,2.5/2.54*300=295,3.5/2.54*300=413 4 | Large One Inch,3.3,4.8,390,567,300,jpg,,,photo,3.3/2.54*300=390,4.8/2.54*300=567 5 | Small Two Inch,3.3,4.8,390,567,300,jpg,,,photo,3.3/2.54*300=390,4.8/2.54*300=567 6 | Two Inch (ID Photo),3.7,4.9,437,579,300,jpg,,,photo,3.5/2.54*300=437,5.3/2.54*300=579 7 | Large Two Inch (Standard),3.7,5.3,437,626,300,jpg,,,photo,3.5/2.54*300=437,5.3/2.54*300=626 8 | Five Inch,8.9,12.7,1051,1500,300,jpg,,,both,Using 3R standard size 9 | Six Inch,10.2,15.2,1205,1795,300,jpg,,,both,Using 4R standard size 10 | Chinese Driver's License,2.2,3.2,260,378,300,jpg,14,30,photo,Same as Small One Inch size 11 | Chinese ID Card,2.6,3.2,358,441,350,jpg,,,photo,Chinese ID Card Resolution is 350dpi 12 | Chinese Social Security Photo,2.6,3.2,358,441,350,jpg,9,20,photo,Same size and resolution as Chinese ID Card 13 | Chinese Adult Higher Education Exam,4.8,6.4,567,756,300,jpg,20,6144,photo,4.8/2.54*300=567,6.4/2.54*300=756 14 | Chinese Art Exam Application Photo,3.7,5.3,437,626,300,jpg,,,photo,Same as Large Two Inch (Standard) 15 | Chinese Mandarin Proficiency Test Photo,3.3,4.8,390,567,300,jpg,,,photo,Same as Large One Inch size 16 | Chinese Civil Service Exam,2.5,3.5,295,413,300,jpg,,,photo,Same as One Inch size 17 | Chinese College Entrance Exam Photo,4.06,5.41,480,640,300,jpg,50,200,photo,(Varies by region) PrintWidth=480/300*2.54=4.06cm,PrintHeight=640/300*2.54=5.41cm 18 | Chinese Self-study Higher Education Exam,1.2,1.6,142,189,300,jpg,,,photo,(Varies by region) 1.2/2.54*300=142,1.6/2.54*300=189 19 | Chinese Nurse Qualification Exam Photo,2.5,3.5,295,413,300,jpg,45,,photo,Same as One Inch size 20 | Chinese Teacher Qualification Certificate Photo,3.0,4.1,354,484,300,jpg,,,photo,3.0/2.54*300=354,4.1/2.54*300=484 21 | Chinese National Computer Rank Exam,3.3,4.8,390,567,300,jpg,20,200,photo,Same as Large One Inch size 22 | Chinese Japanese Language Proficiency Test,3.0,4.0,354,472,300,jpg/jpeg,20,200,photo,3.0/2.54*300=354,4.0/2.54*300=472 23 | Chinese National English Test,3.3,4.8,390,567,300,jpg,20,200,photo,Same as Large One Inch size 24 | Chinese Level 2 Computer Exam,3.3,4.8,390,567,300,jpg,20,200,photo,Same as Large One Inch size 25 | Chinese Primary School Teacher Qualification,1.27,1.69,150,200,300,jpg/jpeg,,200,photo,PrintWidth=150/300*2.54=1.27cm,PrintHeight=200/300*2.54=1.69cm 26 | Chinese Junior Accountant Exam,2.5,3.5,295,413,300,jpg,,,photo,Same as One Inch size 27 | Chinese CET-4/6 Exam,1.2,1.6,142,189,300,jpg,20,200,photo,(Varies by region) 28 | Chinese Graduate Entrance Exam,2.5,3.5,295,413,300,jpg,,10240,photo,Same as One Inch size 29 | Chinese Social Security Card,2.6,3.2,358,441,350,jpg,,,photo,Same size and resolution as ID Card 30 | Chinese Electronic Driver's License,2.2,3.2,260,378,300,jpg,,,photo,Same as Small One Inch size 31 | Chinese US Visa Photo,5.08,5.08,600,600,300,jpg,,,photo,PrintWidth=PrintHeight=600/300*2.54=5.08cm 32 | Chinese Japan Visa Photo,4.5,4.5,531,531,300,jpg,,,photo,4.5/2.54*300=531 33 | Chinese Korea Visa Photo,3.5,4.5,413,531,300,jpg,,,photo,3.5/2.54*300=413,4.5/2.54*300=531 34 | A4,21.0,29.7,2480,3508,300,jpg,,,sheet,21.0/2.54*300=2480,29.7/2.54*300=3508 35 | 3R,8.9,12.7,1051,1500,300,jpg,,,sheet, 36 | 4R,10.2,15.2,1205,1795,300,jpg,,,sheet, 37 | -------------------------------------------------------------------------------- /data/size_zh.csv: -------------------------------------------------------------------------------- 1 | Name,PrintWidth,PrintHeight,ElectronicWidth,ElectronicHeight,Resolution,FileFormat,FileSizeMin,FileSizeMax,Type,Notes 2 | 小一寸,2.2,3.2,260,378,300,jpg,,,photo,2.2/2.54*300=260,3.2/2.54*300=378 3 | 一寸,2.5,3.5,295,413,300,jpg,,,photo,2.5/2.54*300=295,3.5/2.54*300=413 4 | 大一寸,3.3,4.8,390,567,300,jpg,,,photo,3.3/2.54*300=390,4.8/2.54*300=567 5 | 小二寸,3.3,4.8,390,567,300,jpg,,,photo,3.3/2.54*300=390,4.8/2.54*300=567 6 | 二寸 (证件照),3.7,4.9,437,579,300,jpg,,,photo,3.5/2.54*300=437,5.3/2.54*300=579 7 | 大二寸 (标准2寸),3.7,5.3,437,626,300,jpg,,,photo,3.5/2.54*300=437,5.3/2.54*300=626 8 | 五寸,8.9,12.7,1051,1500,300,jpg,,,both,使用3R标准尺寸 9 | 六寸,10.2,15.2,1205,1795,300,jpg,,,both,使用4R标准尺寸 10 | 中国驾驶证,2.2,3.2,260,378,300,jpg,14,30,photo,同小一寸尺寸 11 | 中国居民身份证,2.6,3.2,358,441,350,jpg,,,photo,中国居民身份证 Resolution为350dpi 12 | 中国社保照片,2.6,3.2,358,441,350,jpg,9,20,photo,同中国居民身份证尺寸和分辨率 13 | 中国成人高等教育考试,4.8,6.4,567,756,300,jpg,20,6144,photo,4.8/2.54*300=567,6.4/2.54*300=756 14 | 中国艺考报名照片,3.7,5.3,437,626,300,jpg,,,photo,同大二寸 (标准2寸) 15 | 中国普通话水平考试照片,3.3,4.8,390,567,300,jpg,,,photo,同大一寸尺寸 16 | 中国国家公务员考试,2.5,3.5,295,413,300,jpg,,,photo,同一寸尺寸 17 | 中国高考报名照片,4.06,5.41,480,640,300,jpg,50,200,photo,(因地区而异)PrintWidth=480/300*2.54=4.06cm,PrintHeight=640/300*2.54=5.41cm 18 | 中国全国高等教育自学考试,1.2,1.6,142,189,300,jpg,,,photo,(因地区而异)1.2/2.54*300=142,1.6/2.54*300=189 19 | 中国护士执业资格考试照片,2.5,3.5,295,413,300,jpg,45,,photo,同一寸尺寸 20 | 中国教师资格证照片,3.0,4.1,354,484,300,jpg,,,photo,3.0/2.54*300=354,4.1/2.54*300=484 21 | 中国全国计算机等级考试,3.3,4.8,390,567,300,jpg,20,200,photo,同大一寸尺寸 22 | 中国日本语能力考试,3.0,4.0,354,472,300,jpg/jpeg,20,200,photo,3.0/2.54*300=354,4.0/2.54*300=472 23 | 中国全国英语等级考试,3.3,4.8,390,567,300,jpg,20,200,photo,同大一寸尺寸 24 | 中国二级计算机考试,3.3,4.8,390,567,300,jpg,20,200,photo,同大一寸尺寸 25 | 中国小学教师资格证,1.27,1.69,150,200,300,jpg/jpeg,,200,photo,PrintWidth=150/300*2.54=1.27cm,PrintHeight=200/300*2.54=1.69cm 26 | 中国初级会计考试,2.5,3.5,295,413,300,jpg,,,photo,同一寸尺寸 27 | 中国英语四六级考试,1.2,1.6,142,189,300,jpg,20,200,photo,(因地区而异) 28 | 中国研究生考试,2.5,3.5,295,413,300,jpg,,10240,photo,同一寸尺寸 29 | 中国社保卡,2.6,3.2,358,441,350,jpg,,,photo,同居民身份证尺寸和分辨率 30 | 中国电子驾驶证,2.2,3.2,260,378,300,jpg,,,photo,同小一寸尺寸 31 | 中国美国签证,5.08,5.08,600,600,300,jpg,,,photo,PrintWidth=PrintHeight=600/300*2.54=5.08cm 32 | 中国日本签证,4.5,4.5,531,531,300,jpg,,,photo,4.5/2.54*300=531 33 | 中国韩国签证,3.5,4.5,413,531,300,jpg,,,photo,3.5/2.54*300=413,4.5/2.54*300=531 34 | A4,21.0,29.7,2480,3508,300,jpg,,,sheet,21.0/2.54*300=2480,29.7/2.54*300=3508 35 | 3R,8.9,12.7,1051,1500,300,jpg,,,sheet, 36 | 4R,10.2,15.2,1205,1795,300,jpg,,,sheet, -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. -------------------------------------------------------------------------------- /docs/README-EN.md: -------------------------------------------------------------------------------- 1 | # LiYing 2 | 3 | [简体中文](./README.md) | English 4 | 5 | LiYing is an automated photo processing program designed for automating general post-processing workflows in photo studios. 6 | 7 | ## Introduction 8 | 9 | LiYing can automatically identify human bodies and faces, correct angles, change background colors, crop passport photos to any size, and automatically arrange them. 10 | 11 | LiYing can run completely offline. All image processing operations are performed locally. 12 | 13 | ### Simple Workflow Description 14 | 15 | ![workflows](../images/workflows.png) 16 | 17 | ### Demonstration 18 | 19 | | ![test1](../images/test1.jpg) | ![test2](../images/test2.jpg) | ![test3](../images/test3.jpg) | 20 | | ----------------------------- | ---------------------------- | ---------------------------- | 21 | | ![test1_output_sheet](../images/test1_output_sheet.jpg)(1-inch on 5-inch photo paper - 3x3) | ![test2_output_sheet](../images/test2_output_sheet.jpg)(2-inch on 5-inch photo paper - 2x2) | ![test3_output_sheet](../images/test3_output_sheet.jpg)(1-inch on 6-inch photo paper - 4x2) | 22 | 23 | **Note: This project is specifically for processing passport photos and may not work perfectly on any arbitrary image. The input images should be standard single-person portrait photos.** 24 | 25 | **It is normal for unexpected results to occur if you use complex images to create passport photos.** 26 | 27 | ## Getting Started 28 | 29 | ### Bundled Package 30 | 31 | If you are a Windows user and do not need to review the code, you can [download the bundled package](https://github.com/aoguai/LiYing/releases/latest) (tested on Windows 7 SP1 & Windows 10). 32 | 33 | The bundled package does not include any models. You can refer to the [Downloading the Required Models](https://github.com/aoguai/LiYing/blob/master/docs/README-EN.md#downloading-the-required-models) section for instructions on downloading the models and placing them in the correct directory. 34 | 35 | If you encounter issues while running the program, please first check the [Prerequisites](https://github.com/aoguai/LiYing/blob/master/docs/README-EN.md#prerequisites) section to ensure your environment is properly set up. If everything is fine, you can ignore this step. 36 | 37 | #### Running the bundled package 38 | 39 | Run the BAT script: 40 | ```shell 41 | cd LiYing 42 | run.bat ./images/test1.jpg 43 | ``` 44 | 45 | Run the WebUI interface: 46 | ```shell 47 | # Run WebUI 48 | cd LiYing 49 | run_webui.bat 50 | # Open your browser and visit 127.0.0.1:7860 51 | ``` 52 | 53 | ### Setup and Installation 54 | 55 | You can install and configure LiYing locally by following the instructions below. 56 | 57 | #### Prerequisites 58 | 59 | LiYing depends on AGPicCompress, which in turn requires `mozjpeg` and `pngquant`. 60 | 61 | You may need to manually install `pngquant`. Refer to the [official pngquant documentation](https://pngquant.org/) and add it to the appropriate location. 62 | 63 | LiYing checks for `pngquant` in the following locations, which you can configure: 64 | - Environment variables (recommended) 65 | - LiYing/src directory 66 | - `ext` directory under LiYing/src 67 | 68 | This allows AGPicCompress to locate and use `pngquant` for PNG image compression. 69 | 70 | ##### Microsoft Visual C++ Redistributable Dependency 71 | 72 | You need to install the latest [Microsoft Visual C++ Redistributable](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist). 73 | 74 | If you are using Windows, your minimum version should be Windows 7 SP1 or higher. 75 | 76 | #### Building from Source 77 | 78 | You can obtain the LiYing project code by running: 79 | 80 | ```shell 81 | git clone https://github.com/aoguai/LiYing 82 | cd LiYing ## Enter the LiYing directory 83 | pip install -r requirements.txt # Install Python helpers' dependencies 84 | ``` 85 | 86 | **Note: If you are using Windows 7, ensure you have at least Windows 7 SP1 and `onnxruntime==1.14.0, orjson==3.10.7, gradio==4.44.1`.** 87 | 88 | #### Downloading the Required Models 89 | 90 | Download the models used by the project and place them in `LiYing/src/model`, or specify the model paths in the command line. 91 | 92 | | Purpose | Model Name | Download Link | Source | 93 | |---------------------------|-------------------|----------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------| 94 | | Face Recognition | Yunnet | [Download Link](https://github.com/opencv/opencv_zoo/blob/main/models/face_detection_yunet/face_detection_yunet_2023mar.onnx) | [Yunnet](https://github.com/ShiqiYu/libfacedetection) | 95 | | Subject Recognition and Background Replacement | RMBG-1.4 | [Download Link](https://huggingface.co/briaai/RMBG-1.4/blob/main/onnx/model.onnx) | [RMBG-1.4](https://huggingface.co/briaai/RMBG-1.4) | 96 | | Body Recognition | yolov8n-pose | [Download Link](https://github.com/ultralytics/assets/releases/download/v8.2.0/yolov8n-pose.pt) | [ultralytics](https://github.com/ultralytics/ultralytics) | 97 | 98 | **Note: For the yolov8n-pose model, you need to export it to an ONNX model. Refer to the [official documentation](https://docs.ultralytics.com/integrations/onnx/) for instructions.** 99 | 100 | We also provide pre-converted ONNX models that you can download and use directly: 101 | 102 | | Download Method | Link | 103 | |-----------------|--------------------------------------------------------------------------------| 104 | | Google Drive | [Download Link](https://drive.google.com/file/d/1F8EQfwkeq4s-P2W4xQjD28c4rxPuX1R3/view) | 105 | | Baidu Netdisk | [Download Link (Extraction Code: ahr9)](https://pan.baidu.com/s/1QhzW53vCbhkIzvrncRqJow?pwd=ahr9) | 106 | | GitHub Releases | [Download Link](https://github.com/aoguai/LiYing/releases/latest) | 107 | 108 | #### Running 109 | 110 | ```shell 111 | # View CIL help 112 | cd LiYing/src 113 | python main.py --help 114 | ``` 115 | 116 | For Windows users, the project provides a batch script for convenience: 117 | 118 | ```shell 119 | # Run BAT script 120 | cd LiYing 121 | run.bat ./images/test1.jpg 122 | ``` 123 | 124 | ```shell 125 | # Run WebUI 126 | cd LiYing/src/webui 127 | python app.py 128 | ``` 129 | 130 | #### CLI Parameters and Help 131 | 132 | ```shell 133 | python main.py --help 134 | Usage: main.py [OPTIONS] IMG_PATH 135 | 136 | Options: 137 | -y, --yolov8-model-path PATH Path to YOLOv8 model 138 | -u, --yunet-model-path PATH Path to YuNet model 139 | -r, --rmbg-model-path PATH Path to RMBG model 140 | -sz, --size-config PATH Path to size configuration file 141 | -cl, --color-config PATH Path to color configuration file 142 | -b, --rgb-list RGB_LIST RGB channel values list (comma-separated) 143 | for image composition 144 | -s, --save-path PATH Path to save the output image 145 | -p, --photo-type TEXT Photo types 146 | -ps, --photo-sheet-size TEXT Size of the photo sheet 147 | -c, --compress / --no-compress Whether to compress the image 148 | -sv, --save-corrected / --no-save-corrected 149 | Whether to save the corrected image 150 | -bg, --change-background / --no-change-background 151 | Whether to change the background 152 | -sb, --save-background / --no-save-background 153 | Whether to save the image with changed 154 | background 155 | -lo, --layout-only Only layout the photo without changing 156 | background 157 | -sr, --sheet-rows INTEGER Number of rows in the photo sheet 158 | -sc, --sheet-cols INTEGER Number of columns in the photo sheet 159 | -rt, --rotate / --no-rotate Whether to rotate the photo by 90 degrees 160 | -rs, --resize / --no-resize Whether to resize the image 161 | -svr, --save-resized / --no-save-resized 162 | Whether to save the resized image 163 | -al, --add-crop-lines / --no-add-crop-lines 164 | Add crop lines to the photo sheet 165 | -ts, --target-size INTEGER Target file size in KB. When specified, 166 | ignores quality and size-range. 167 | -szr, --size-range SIZE_RANGE File size range in KB as min,max (e.g., 168 | 10,20) 169 | -uc, --use-csv-size / --no-use-csv-size 170 | Whether to use file size limits from CSV 171 | --help Show this message and exit. 172 | 173 | ``` 174 | 175 | #### Configuration Files 176 | 177 | In this version, the `data` directory contains standard ID photo configuration files (`size_XX.csv`) and commonly used color configurations (`color_XX.csv`). You can modify, add, or remove configurations based on the provided CSV template format. 178 | 179 | ## Changelog 180 | 181 | **Note: This version includes changes to CIL parameters. Please carefully read the latest CIL help documentation to avoid issues.** 182 | 183 | - **2025/02/07 Update** 184 | - **Added WebUI** 185 | - Optimized configuration method by replacing INI files with CSV 186 | - Added CI/CD for automated builds and testing 187 | - Added options for layout-only photos and whether to add crop lines on the photo grid 188 | - Improved fallback handling for non-face images 189 | - Fixed known bugs 190 | - Added and refined more photo sizes 191 | 192 |
193 | Previous Changelog 194 | 195 | - **2024/08/06 Update** 196 | - Added support for entering width and height in pixels directly for `photo-type` and `photo-sheet-size`, and support for configuration via `data.ini`. 197 | - Fixed issues related to some i18n configurations; now compatible with both English and Chinese settings. 198 | - Fixed other known bugs. 199 |
200 | 201 | 202 | ## Acknowledgments 203 | 204 | The project was created to help my parents complete their work more easily. I would like to thank my parents for their support. 205 | 206 | ### Related Projects 207 | 208 | Special thanks to the following projects and contributors for providing models and theories: 209 | 210 | - [Yunnet](https://github.com/ShiqiYu/libfacedetection) 211 | - [RMBG-1.4](https://huggingface.co/briaai/RMBG-1.4) 212 | - [ultralytics](https://github.com/ultralytics/ultralytics) 213 | 214 | You might also be interested in the image compression part, which is another open-source project of mine: 215 | 216 | - [AGPicCompress](https://github.com/aoguai/AGPicCompress) 217 | 218 | It depends on: 219 | 220 | - [mozjpeg](https://github.com/mozilla/mozjpeg) 221 | - [pngquant](https://github.com/kornelski/pngquant) 222 | - [mozjpeg-lossless-optimization](https://github.com/wanadev/mozjpeg-lossless-optimization) 223 | 224 | ## Contribution 225 | 226 | LiYing is an open-source project, and community participation is highly welcomed. To contribute to this project, please follow the [Contribution Guide](./CONTRIBUTING.md). 227 | 228 | ## License 229 | 230 | [LiYing](https://github.com/aoguai/LiYing) is open-sourced under the AGPL-3.0 license. For details, please refer to the [LICENSE](../LICENSE) file. 231 | 232 | 233 | ## Star History 234 | 235 | 236 | 237 | 238 | 239 | Star History Chart 240 | 241 | 242 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # LiYing 2 | 3 | 简体中文 | [English](./README-EN.md) 4 | 5 | LiYing 是一套适用于自动化完成一般照相馆后期流程的照片自动处理的程序。 6 | 7 | ## 介绍 8 | 9 | LiYing 可以完成人体、人脸自动识别,角度自动纠正,自动更换任意背景色,任意尺寸证件照自动裁切,并自动排版。 10 | 11 | LiYing 可以完全离线运行。所有图像处理操作都在本地运行。 12 | 13 | ### 简单工作流说明 14 | 15 | ![workflows](../images/workflows.png) 16 | 17 | ### 效果展示 18 | 19 | | ![test1](../images/test1.jpg) | ![test2](../images/test2.jpg) | ![test3](../images/test3.jpg) | 20 | | ----------------------------- | ---------------------------- | ---------------------------- | 21 | | ![test1_output_sheet](../images/test1_output_sheet.jpg)(1寸-5寸相片纸-3*3) | ![test2_output_sheet](../images/test2_output_sheet.jpg)(2寸-5寸相片纸-2*2) | ![test3_output_sheet](../images/test3_output_sheet.jpg)(1寸-6寸相片纸-4*2) | 22 | 23 | **注:本项目仅针对证件照图像处理,而非要求任意照片图像都可以完美执行,所以该项目的输入图片应该是符合一般要求的单人肖像照片。** 24 | 25 | **如果您使用复杂图片制作证件照出现意外情况属于正常现象。** 26 | 27 | ## 开始使用 28 | 29 | ### 整合包 30 | 31 | 如果你是 Windows 用户且没有代码阅览需求,可以[下载整合包](https://github.com/aoguai/LiYing/releases/latest)(已在 Windows 7 SP1 & Windows 10 测试) 32 | 33 | 整合包从未包含模型,您可以参考 [下载对应模型](https://github.com/aoguai/LiYing?tab=readme-ov-file#%E4%B8%8B%E8%BD%BD%E5%AF%B9%E5%BA%94%E6%A8%A1%E5%9E%8B) 章节说明来下载模型并放入正确的位置 34 | 35 | 同时如果运行存在问题,请先尝试按照 [先决条件](https://github.com/aoguai/LiYing?tab=readme-ov-file#%E5%85%88%E5%86%B3%E6%9D%A1%E4%BB%B6) 章节完善环境,如果没问题可以忽略 36 | 37 | #### 运行整合包 38 | 39 | 运行 BAT 脚本 40 | ```shell 41 | cd LiYing 42 | run.bat ./images/test1.jpg 43 | ``` 44 | 45 | 运行 WebUI 界面 46 | ```shell 47 | # 运行 WebUI 48 | cd LiYing 49 | run_webui.bat 50 | # 浏览器访问 127.0.0.1:7860 51 | ``` 52 | 53 | ### 设置和安装 54 | 55 | 您可以按照以下说明进行安装和配置,从而在本地环境中使用 LiYing。 56 | 57 | #### 先决条件 58 | 59 | LiYing 依赖于 AGPicCompress ,而 AGPicCompress 需要依赖于 mozjpeg 和 pngquant 60 | 61 | 其中你可能需要手动安装 pngquant,你可以参考 [pngquant 官方文档](https://pngquant.org/)并将其添加到对应位置 62 | 63 | LiYing 会在以下位置检测 pngquant 是否存在,你可以自由配置 64 | - 环境变量(推荐) 65 | - LiYing/src 目录下 66 | - LiYing/src 目录下的 `ext` 目录 67 | 68 | 以便 AGPicCompress 能够找到 pngquant 并使用它进行 PNG 图片的压缩。 69 | 70 | #### Microsoft Visual C++ Redistributable 依赖 71 | 72 | 您需要安装最新 [Microsoft Visual C++ Redistributable 依赖](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist) 73 | 74 | 75 | 如果您使用的是 Windows 系统,您的最低版本应该是 Windows 7 SP1 及以上。 76 | 77 | #### 从源码构建 78 | 79 | 您可以通过以下方式获取 LiYing 项目的代码: 80 | 81 | ```shell 82 | git clone https://github.com/aoguai/LiYing 83 | cd LiYing ## 进入 LiYing 目录 84 | pip install -r requirements.txt # install Python helpers' dependencies 85 | ``` 86 | 87 | **注: 如果您使用的是 Windows 7 系统请您至少需要是 Windows 7 SP1 以上版本,且要求 `onnxruntime==1.14.0, orjson==3.10.7, gradio==4.44.1`** 88 | 89 | #### 下载对应模型 90 | 91 | 您需要下载该项目使用到的模型并将其放置在 `LiYing/src/model` 中。或者您可以在 CIL 中指定模型路径。 92 | 93 | | 用途 | 模型名称 | 下载链接 | 来源 | 94 | |------------------------|--------------------|------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------| 95 | | 人脸识别 | Yunnet | [下载链接](https://github.com/opencv/opencv_zoo/blob/main/models/face_detection_yunet/face_detection_yunet_2023mar.onnx) | [Yunnet](https://github.com/ShiqiYu/libfacedetection) | 96 | | 主体识别替换背景 | RMBG-1.4 | [下载链接](https://huggingface.co/briaai/RMBG-1.4/blob/main/onnx/model.onnx) | [RMBG-1.4](https://huggingface.co/briaai/RMBG-1.4) | 97 | | 人体识别 | yolov8n-pose | [下载链接](https://github.com/ultralytics/assets/releases/download/v8.2.0/yolov8n-pose.pt) | [ultralytics](https://github.com/ultralytics/ultralytics) | 98 | 99 | **注: 对于 yolov8n-pose 模型,您需要将其导出为 ONNX 模型,您可以参考[官方文档](https://docs.ultralytics.com/integrations/onnx/)实现** 100 | 101 | 同时,我们提供了转换好的 ONNX 模型,您可以直接下载使用: 102 | 103 | | 下载方式 | 链接 | 104 | |--------------|--------------------------------------------------------------------------------| 105 | | Google Drive | [下载链接](https://drive.google.com/file/d/1F8EQfwkeq4s-P2W4xQjD28c4rxPuX1R3/view) | 106 | | 百度网盘 | [下载链接(提取码:ahr9)](https://pan.baidu.com/s/1QhzW53vCbhkIzvrncRqJow?pwd=ahr9) | 107 | | Github releases | [下载链接](https://github.com/aoguai/LiYing/releases/latest) | 108 | 109 | #### 运行 110 | 111 | ```shell 112 | # 查看 CIL 帮助 113 | cd LiYing/src 114 | python main.py --help 115 | ``` 116 | 117 | 对于 Window 用户,项目提供了 bat 运行脚本方便您使用: 118 | 119 | ```shell 120 | # 运行 BAT 脚本 121 | cd LiYing 122 | run.bat ./images/test1.jpg 123 | ``` 124 | 125 | ```shell 126 | # 运行 WebUI 127 | cd LiYing/src/webui 128 | python app.py 129 | ``` 130 | 131 | #### CIL 参数信息与帮助 132 | ```shell 133 | python main.py --help 134 | Usage: main.py [OPTIONS] IMG_PATH 135 | 136 | Options: 137 | -y, --yolov8-model-path PATH YOLOv8 模型路径 138 | -u, --yunet-model-path PATH YuNet 模型路径 139 | -r, --rmbg-model-path PATH RMBG 模型路径 140 | -sz, --size-config PATH 尺寸配置文件路径 141 | -cl, --color-config PATH 颜色配置文件路径 142 | -b, --rgb-list RGB_LIST RGB 通道值列表(英文逗号分隔),用于图像合成 143 | -s, --save-path PATH 保存路径 144 | -p, --photo-type TEXT 照片类型 145 | -ps, --photo-sheet-size TEXT 选择照片表格的尺寸 146 | -c, --compress / --no-compress 是否压缩图像(使用 AGPicCompress 压缩) 147 | -sv, --save-corrected / --no-save-corrected 148 | 是否保存修正图像后的图片 149 | -bg, --change-background / --no-change-background 150 | 是否替换背景 151 | -sb, --save-background / --no-save-background 152 | 是否保存替换背景后的图像 153 | -lo, --layout-only 仅排版照片,不更换背景 154 | -sr, --sheet-rows INTEGER 照片表格的行数 155 | -sc, --sheet-cols INTEGER 照片表格的列数 156 | -rt, --rotate / --no-rotate 是否旋转照片90度 157 | -rs, --resize / --no-resize 是否调整图像尺寸 158 | -svr, --save-resized / --no-save-resized 159 | 是否保存调整尺寸后的图像 160 | -al, --add-crop-lines / --no-add-crop-lines 161 | 在照片表格上添加裁剪线 162 | -ts, --target-size INTEGER 目标文件大小(KB)。指定后将忽略质量和大小范围参数。 163 | -szr, --size-range SIZE_RANGE 文件大小范围(KB),格式为最小值,最大值(例如:10,20) 164 | -uc, --use-csv-size / --no-use-csv-size 165 | 是否使用CSV中的文件大小限制 166 | --help Show this message and exit. 167 | 168 | ``` 169 | 170 | #### 配置文件 171 | 172 | 在该版本中,在`data`目录中设置了常规的证件照配置`size_XX.csv`与常用颜色配置`color_XX.csv`,您可以自行按照给出的 CSV 模板格式修改或增删配置。 173 | 174 | ## 更新日志 175 | 176 | **注意该版本对 CIL 参数进行了更改,为了避免问题请你仔细阅读最新 CIL 帮助文档** 177 | 178 | - **2025/02/07 更新** 179 | - **添加 WebUI** 180 | - 优化 配置方式,用 CSV 替换 INI 配置 181 | - 添加 CI/CD 方便自动构建与测试 182 | - 添加 仅排版照片, 是否在照片表格上添加裁剪线 选项 183 | - 完善 对非脸部图像的兜底处理 184 | - 修复 已知BUG 185 | - 添加修正补充了更多尺寸 186 |
187 | 往期更新日志 188 | 189 | - **2024/08/06 更新** 190 | - 新增 photo-type 和 photo-sheet-size 支持直接输入宽高像素,支持使用 data.ini 配置 191 | - 修复 部分 i18n 导致的已知问题,现在可以兼容中英文配置 192 | - 修复 其他已知BUG 193 |
194 | 195 | ## 致谢 196 | 197 | 该项目的制作初衷和项目名称来源于帮助我的父母更轻松的完成他们的工作,在此感谢我的父母。 198 | 199 | ### 相关 200 | 201 | 同时特别感谢以下项目和贡献者: 202 | 203 | 提供模型与理论 204 | 205 | - [Yunnet](https://github.com/ShiqiYu/libfacedetection) 206 | - [RMBG-1.4](https://huggingface.co/briaai/RMBG-1.4) 207 | - [ultralytics](https://github.com/ultralytics/ultralytics) 208 | 209 | 或许你会对图片压缩部分感兴趣,那是我另一个开源项目 210 | 211 | - [AGPicCompress](https://github.com/aoguai/AGPicCompress) 212 | 213 | 它依赖于 214 | 215 | - [mozjpeg](https://github.com/mozilla/mozjpeg) 216 | - [pngquant](https://github.com/kornelski/pngquant) 217 | - [mozjpeg-lossless-optimization](https://github.com/wanadev/mozjpeg-lossless-optimization) 218 | 219 | ## 贡献 220 | 221 | LiYing 是一个开源项目,非常欢迎社区的参与。要为该项目做出贡献,请遵循[贡献指南](./CONTRIBUTING.md)。 222 | 223 | ## License 说明 224 | 225 | [LiYing](https://github.com/aoguai/LiYing) 使用 AGPL-3.0 license 进行开源,详情请参阅 [LICENSE](../LICENSE) 文件。 226 | 227 | ## Star History 228 | 229 | 230 | 231 | 232 | 233 | Star History Chart 234 | 235 | 236 | -------------------------------------------------------------------------------- /images/test1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aoguai/LiYing/006d52a10006daa1b885cbca064a2158400107c4/images/test1.jpg -------------------------------------------------------------------------------- /images/test1_output_background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aoguai/LiYing/006d52a10006daa1b885cbca064a2158400107c4/images/test1_output_background.jpg -------------------------------------------------------------------------------- /images/test1_output_corrected.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aoguai/LiYing/006d52a10006daa1b885cbca064a2158400107c4/images/test1_output_corrected.jpg -------------------------------------------------------------------------------- /images/test1_output_resized.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aoguai/LiYing/006d52a10006daa1b885cbca064a2158400107c4/images/test1_output_resized.jpg -------------------------------------------------------------------------------- /images/test1_output_sheet.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aoguai/LiYing/006d52a10006daa1b885cbca064a2158400107c4/images/test1_output_sheet.jpg -------------------------------------------------------------------------------- /images/test2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aoguai/LiYing/006d52a10006daa1b885cbca064a2158400107c4/images/test2.jpg -------------------------------------------------------------------------------- /images/test2_output_sheet.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aoguai/LiYing/006d52a10006daa1b885cbca064a2158400107c4/images/test2_output_sheet.jpg -------------------------------------------------------------------------------- /images/test3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aoguai/LiYing/006d52a10006daa1b885cbca064a2158400107c4/images/test3.jpg -------------------------------------------------------------------------------- /images/test3_output_sheet.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aoguai/LiYing/006d52a10006daa1b885cbca064a2158400107c4/images/test3_output_sheet.jpg -------------------------------------------------------------------------------- /images/workflows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aoguai/LiYing/006d52a10006daa1b885cbca064a2158400107c4/images/workflows.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click 2 | colorama 3 | mozjpeg_lossless_optimization 4 | onnxruntime>=1.14.0 5 | orjson>=3.10.7 6 | gradio>=4.44.1 7 | Pillow 8 | opencv-python 9 | numpy 10 | piexif -------------------------------------------------------------------------------- /run_en.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | set CLI_LANGUAGE=en 3 | setlocal enabledelayedexpansion 4 | 5 | REM Get the current batch file directory 6 | set SCRIPT_DIR=%~dp0 7 | 8 | REM Set Python interpreter path and project directory 9 | set PYTHON_EXE=%SCRIPT_DIR%python-embed\python.exe 10 | set SCRIPT_PATH=%SCRIPT_DIR%src\main.py 11 | 12 | REM Check if files or directories were dragged and dropped 13 | if "%~1"=="" ( 14 | echo Please drag and drop image files or directories onto this script 15 | pause 16 | exit /b 17 | ) 18 | 19 | REM Get the dropped path 20 | set INPUT_PATH=%~1 21 | 22 | echo LiYing 23 | echo Github: https://github.com/aoguai/LiYing 24 | echo LICENSE AGPL-3.0 license 25 | echo ---------------------------------------- 26 | 27 | REM Prompt user for input parameters 28 | set /p "layout_only=Layout only without changing background (yes/no, default is no): " 29 | if /i "!layout_only!"=="yes" || /i "!layout_only!"=="y" ( 30 | set layout_only=--layout-only 31 | set change_background=--no-change-background 32 | set save_background=--no-save-background 33 | set rgb_list=255,255,255 34 | ) else ( 35 | set layout_only= 36 | set /p "change_background=Change background (yes/no, default is no): " 37 | if /i "!change_background!"=="yes" || /i "!change_background!"=="y" ( 38 | set change_background=--change-background 39 | set /p "rgb_list=Enter RGB channel values (comma separated, default is 255,255,255): " 40 | if "!rgb_list!"=="red" set rgb_list=255,0,0 41 | if "!rgb_list!"=="blue" set rgb_list=12,92,165 42 | if "!rgb_list!"=="white" set rgb_list=255,255,255 43 | if "!rgb_list!"=="" set rgb_list=255,255,255 44 | set /p "save_background=Save images with changed background (yes/no, default is no): " 45 | if /i "!save_background!"=="yes" || /i "!save_background!"=="y" ( 46 | set save_background=--save-background 47 | ) else ( 48 | set save_background=--no-save-background 49 | ) 50 | ) else ( 51 | set change_background=--no-change-background 52 | set save_background=--no-save-background 53 | set rgb_list=255,255,255 54 | ) 55 | ) 56 | 57 | set /p "resize=Resize images (yes/no, default is yes): " 58 | if /i "!resize!"=="no" || /i "!resize!"=="n" ( 59 | set resize=--no-resize 60 | set save_resized=--no-save-resized 61 | ) else ( 62 | set resize=--resize 63 | set /p "save_resized=Save resized images (yes/no, default is no): " 64 | if /i "!save_resized!"=="yes" || /i "!save_resized!"=="y" ( 65 | set save_resized=--save-resized 66 | ) else ( 67 | set save_resized=--no-save-resized 68 | ) 69 | set /p "photo_type=Enter photo type (default is one_inch): " 70 | if "!photo_type!"=="" set photo_type=one_inch 71 | ) 72 | 73 | set /p "photo_sheet_size=Enter photo sheet size (default is five_inch): " 74 | if "!photo_sheet_size!"=="" set photo_sheet_size=five_inch 75 | 76 | set /p "compress=Compress images (yes/no, default is no): " 77 | if /i "!compress!"=="yes" || /i "!compress!"=="y" ( 78 | set compress=--compress 79 | set /p "use_csv_size=Use size limits from CSV file (yes/no, default is yes): " 80 | if /i "!use_csv_size!"=="no" || /i "!use_csv_size!"=="n" ( 81 | set use_csv_size=--no-use-csv-size 82 | set /p "target_size=Enter target file size in KB (press Enter to skip): " 83 | if "!target_size!"=="" ( 84 | set target_size= 85 | set /p "use_size_range=Do you want to set a file size range? (yes/no, default is no): " 86 | if /i "!use_size_range!"=="yes" || /i "!use_size_range!"=="y" ( 87 | set /p "size_range=Enter file size range in KB (format: min,max, e.g., 10,20): " 88 | if "!size_range!"=="" ( 89 | set size_range= 90 | ) else ( 91 | set size_range=--size-range !size_range! 92 | ) 93 | ) 94 | ) else ( 95 | set target_size=--target-size !target_size! 96 | ) 97 | ) else ( 98 | set use_csv_size=--use-csv-size 99 | set target_size= 100 | set size_range= 101 | ) 102 | ) else ( 103 | set compress=--no-compress 104 | set use_csv_size=--use-csv-size 105 | set target_size= 106 | set size_range= 107 | ) 108 | 109 | set /p "save_corrected=Save corrected images (yes/no, default is no): " 110 | if /i "!save_corrected!"=="yes" || /i "!save_corrected!"=="y" ( 111 | set save_corrected=--save-corrected 112 | ) else ( 113 | set save_corrected=--no-save-corrected 114 | ) 115 | 116 | set /p "sheet_rows=Enter the number of rows in the photo sheet (default is 3): " 117 | if "!sheet_rows!"=="" set sheet_rows=3 118 | 119 | set /p "sheet_cols=Enter the number of columns in the photo sheet (default is 3): " 120 | if "!sheet_cols!"=="" set sheet_cols=3 121 | 122 | set /p "rotate=Rotate photos 90 degrees (yes/no, default is no): " 123 | if /i "!rotate!"=="yes" || /i "!rotate!"=="y" ( 124 | set rotate=--rotate 125 | ) else ( 126 | set rotate=--no-rotate 127 | ) 128 | 129 | set /p "add_crop_lines=Add crop lines to the photo sheet (yes/no, default is yes): " 130 | if /i "!add_crop_lines!"=="no" || /i "!add_crop_lines!"=="n" ( 131 | set add_crop_lines=--no-add-crop-lines 132 | ) else ( 133 | set add_crop_lines=--add-crop-lines 134 | ) 135 | 136 | REM Check if the dropped item is a file or a directory 137 | if exist "%INPUT_PATH%\" ( 138 | REM If it's a directory, iterate through all jpg and png files in it 139 | for %%f in ("%INPUT_PATH%\*.jpg" "%INPUT_PATH%\*.png") do ( 140 | REM Extract folder path and file name 141 | set "INPUT_FILE=%%~ff" 142 | set "OUTPUT_PATH=%%~dpnf_output%%~xf" 143 | 144 | REM Execute Python script to process the image 145 | start "" cmd /k "%PYTHON_EXE% %SCRIPT_PATH% "%%~ff" -b !rgb_list! -s "%%~dpnf_output%%~xf" -p !photo_type! --photo-sheet-size !photo_sheet_size! !compress! !save_corrected! !change_background! !save_background! -sr !sheet_rows! -sc !sheet_cols! !rotate! !resize! !save_resized! !layout_only! !add_crop_lines! !target_size! !size_range! !use_csv_size! & pause" 146 | ) 147 | ) else ( 148 | REM If it's a file, process the file directly 149 | set INPUT_DIR=%~dp1 150 | set INPUT_FILE=%~nx1 151 | set OUTPUT_PATH=%INPUT_DIR%%~n1_output%~x1 152 | 153 | REM Due to setlocal enabledelayedexpansion, use !variable_name! to reference variables 154 | start "" cmd /k "%PYTHON_EXE% %SCRIPT_PATH% "!INPUT_PATH!" -b !rgb_list! -s "!OUTPUT_PATH!" -p !photo_type! --photo-sheet-size !photo_sheet_size! !compress! !save_corrected! !change_background! !save_background! -sr !sheet_rows! -sc !sheet_cols! !rotate! !resize! !save_resized! !layout_only! !add_crop_lines! !target_size! !size_range! !use_csv_size! & pause" 155 | ) 156 | 157 | pause 158 | -------------------------------------------------------------------------------- /run_webui.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | setlocal enabledelayedexpansion 3 | set SCRIPT_DIR=%~dp0 4 | set PYTHON_EXE=%SCRIPT_DIR%python-embed\python.exe 5 | cd src\webui 6 | %PYTHON_EXE% app.py 7 | pause 8 | -------------------------------------------------------------------------------- /run_zh.bat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aoguai/LiYing/006d52a10006daa1b885cbca064a2158400107c4/run_zh.bat -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aoguai/LiYing/006d52a10006daa1b885cbca064a2158400107c4/src/__init__.py -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import locale 2 | 3 | import click 4 | import os 5 | import sys 6 | import warnings 7 | 8 | # Set the project root directory 9 | current_dir = os.path.dirname(os.path.abspath(__file__)) 10 | PROJECT_ROOT = os.path.dirname(current_dir) 11 | DATA_DIR = os.path.join(PROJECT_ROOT, 'data') 12 | MODEL_DIR = os.path.join(current_dir, 'model') 13 | TOOL_DIR = os.path.join(current_dir, 'tool') 14 | 15 | # Add data and model directories to sys.path 16 | sys.path.extend([DATA_DIR, MODEL_DIR, TOOL_DIR]) 17 | 18 | # Set src directory as PYTHONPATH 19 | sys.path.insert(0, current_dir) 20 | 21 | from tool.ImageProcessor import ImageProcessor 22 | from tool.PhotoSheetGenerator import PhotoSheetGenerator 23 | 24 | from tool.PhotoRequirements import PhotoRequirements 25 | 26 | 27 | class RGBListType(click.ParamType): 28 | name = 'rgb_list' 29 | 30 | def convert(self, value, param, ctx): 31 | if value: 32 | try: 33 | return tuple(int(x) for x in value.split(',')) 34 | except ValueError: 35 | self.fail(f'{value} is not a valid RGB list format. Expected format: INTEGER,INTEGER,INTEGER.') 36 | return 0, 0, 0 # Default value 37 | 38 | 39 | class SizeRangeType(click.ParamType): 40 | name = 'size_range' 41 | 42 | def convert(self, value, param, ctx): 43 | if value: 44 | try: 45 | min_size, max_size = map(int, value.split(',')) 46 | if min_size <= 0 or max_size <= 0: 47 | self.fail(f'Size range values must be greater than 0, got min_size={min_size}, max_size={max_size}') 48 | if min_size >= max_size: 49 | self.fail(f'Minimum size must be less than maximum size, got min_size={min_size}, max_size={max_size}') 50 | return (min_size, max_size) 51 | except ValueError: 52 | self.fail(f'{value} is not a valid size range format. Expected format: MIN_SIZE,MAX_SIZE.') 53 | return None 54 | 55 | 56 | def get_language(): 57 | # Get custom language environment variable 58 | language = os.getenv('CLI_LANGUAGE', '') 59 | if language == '': 60 | # Get system language 61 | system_language, _ = locale.getdefaultlocale() 62 | language = 'en' if system_language and system_language.startswith('en') else 'zh' 63 | return language 64 | return language 65 | 66 | 67 | # Define multilingual support messages 68 | messages = { 69 | 'en': { 70 | 'corrected_saved': 'Corrected image saved to {path}', 71 | 'background_saved': 'Background-changed image saved to {path}', 72 | 'resized_saved': 'Resized image saved to {path}', 73 | 'sheet_saved': 'Photo sheet saved to {path}', 74 | }, 75 | 'zh': { 76 | 'corrected_saved': '裁剪并修正后的图像已保存到 {path}', 77 | 'background_saved': '替换背景后的图像已保存到 {path}', 78 | 'resized_saved': '调整尺寸后的图像已保存到 {path}', 79 | 'sheet_saved': '照片表格已保存到 {path}', 80 | } 81 | } 82 | 83 | 84 | def echo_message(key, **kwargs): 85 | lang = get_language() 86 | message = messages.get(lang, messages['en']).get(key, '') 87 | click.echo(message.format(**kwargs)) 88 | 89 | 90 | @click.command() 91 | @click.argument('img_path', type=click.Path(exists=True, resolve_path=True)) 92 | @click.option('-y', '--yolov8-model-path', type=click.Path(), 93 | default=os.path.join(MODEL_DIR, 'yolov8n-pose.onnx'), 94 | help='Path to YOLOv8 model' if get_language() == 'en' else 'YOLOv8 模型路径') 95 | @click.option('-u', '--yunet-model-path', type=click.Path(), 96 | default=os.path.join(MODEL_DIR, 'face_detection_yunet_2023mar.onnx'), 97 | help='Path to YuNet model' if get_language() == 'en' else 'YuNet 模型路径') 98 | @click.option('-r', '--rmbg-model-path', type=click.Path(), 99 | default=os.path.join(MODEL_DIR, 'RMBG-1.4-model.onnx'), 100 | help='Path to RMBG model' if get_language() == 'en' else 'RMBG 模型路径') 101 | @click.option('-sz', '--size-config', type=click.Path(exists=True), 102 | default=os.path.join(DATA_DIR, f'size_{get_language()}.csv'), 103 | help='Path to size configuration file' if get_language() == 'en' else '尺寸配置文件路径') 104 | @click.option('-cl', '--color-config', type=click.Path(exists=True), 105 | default=os.path.join(DATA_DIR, f'color_{get_language()}.csv'), 106 | help='Path to color configuration file' if get_language() == 'en' else '颜色配置文件路径') 107 | @click.option('-b', '--rgb-list', type=RGBListType(), default='0,0,0', 108 | help='RGB channel values list (comma-separated) for image composition' if get_language() == 'en' else 'RGB 通道值列表(英文逗号分隔),用于图像合成') 109 | @click.option('-s', '--save-path', type=click.Path(), default='output.jpg', 110 | help='Path to save the output image' if get_language() == 'en' else '保存路径') 111 | @click.option('-p', '--photo-type', type=str, default='One Inch' if get_language() == 'en' else '一寸', 112 | help='Photo types' if get_language() == 'en' else '照片类型') 113 | @click.option('-ps', '--photo-sheet-size', type=str, default='Five Inch' if get_language() == 'en' else '五寸', 114 | help='Size of the photo sheet' if get_language() == 'en' else '选择照片表格的尺寸') 115 | @click.option('-c', '--compress/--no-compress', default=False, 116 | help='Whether to compress the image' if get_language() == 'en' else '是否压缩图像(使用 AGPicCompress 压缩)') 117 | @click.option('-sv', '--save-corrected/--no-save-corrected', default=False, 118 | help='Whether to save the corrected image' if get_language() == 'en' else '是否保存修正图像后的图片') 119 | @click.option('-bg', '--change-background/--no-change-background', default=False, 120 | help='Whether to change the background' if get_language() == 'en' else '是否替换背景') 121 | @click.option('-sb', '--save-background/--no-save-background', default=False, 122 | help='Whether to save the image with changed background' if get_language() == 'en' else '是否保存替换背景后的图像') 123 | @click.option('-lo', '--layout-only', is_flag=True, default=False, 124 | help='Only layout the photo without changing background' if get_language() == 'en' else '仅排版照片,不更换背景') 125 | @click.option('-sr', '--sheet-rows', type=int, default=3, 126 | help='Number of rows in the photo sheet' if get_language() == 'en' else '照片表格的行数') 127 | @click.option('-sc', '--sheet-cols', type=int, default=3, 128 | help='Number of columns in the photo sheet' if get_language() == 'en' else '照片表格的列数') 129 | @click.option('-rt', '--rotate/--no-rotate', default=False, 130 | help='Whether to rotate the photo by 90 degrees' if get_language() == 'en' else '是否旋转照片90度') 131 | @click.option('-rs', '--resize/--no-resize', default=True, 132 | help='Whether to resize the image' if get_language() == 'en' else '是否调整图像尺寸') 133 | @click.option('-svr', '--save-resized/--no-save-resized', default=False, 134 | help='Whether to save the resized image' if get_language() == 'en' else '是否保存调整尺寸后的图像') 135 | @click.option('-al', '--add-crop-lines/--no-add-crop-lines', default=True, 136 | help='Add crop lines to the photo sheet' if get_language() == 'en' else '在照片表格上添加裁剪线') 137 | @click.option('-ts', '--target-size', type=int, 138 | help='Target file size in KB. When specified, ignores quality and size-range.' if get_language() == 'en' else '目标文件大小(KB)。指定后将忽略质量和大小范围参数。') 139 | @click.option('-szr', '--size-range', type=SizeRangeType(), 140 | help='File size range in KB as min,max (e.g., 10,20)' if get_language() == 'en' else '文件大小范围(KB),格式为最小值,最大值(例如:10,20)') 141 | @click.option('-uc', '--use-csv-size/--no-use-csv-size', default=True, 142 | help='Whether to use file size limits from CSV' if get_language() == 'en' else '是否使用CSV中的文件大小限制') 143 | def cli(img_path, yolov8_model_path, yunet_model_path, rmbg_model_path, size_config, color_config, rgb_list, save_path, 144 | photo_type, photo_sheet_size, compress, save_corrected, change_background, save_background, layout_only, sheet_rows, 145 | sheet_cols, rotate, resize, save_resized, add_crop_lines, target_size, size_range, use_csv_size): 146 | # Parameter validation 147 | if target_size is not None and size_range is not None: 148 | warnings.warn("Both target_size and size_range are provided. Using target_size and ignoring size_range.") 149 | size_range = None 150 | 151 | # Create an instance of the image processor 152 | processor = ImageProcessor(img_path, yolov8_model_path, yunet_model_path, rmbg_model_path, rgb_list, y_b=compress) 153 | photo_requirements = PhotoRequirements(get_language(), size_config, color_config) 154 | 155 | # Get file size limits from CSV if enabled 156 | file_size_limits = {} 157 | if use_csv_size: 158 | file_size_limits = photo_requirements.get_file_size_limits(photo_type) 159 | 160 | # User-provided limits override CSV limits 161 | if target_size is not None: 162 | file_size_limits = {'target_size': target_size} 163 | elif size_range is not None: 164 | file_size_limits = {'size_range': size_range} 165 | 166 | # Crop and correct image 167 | processor.crop_and_correct_image() 168 | if save_corrected: 169 | corrected_path = os.path.splitext(save_path)[0] + '_corrected' + os.path.splitext(save_path)[1] 170 | processor.save_photos(corrected_path, compress, **file_size_limits) 171 | echo_message('corrected_saved', path=corrected_path) 172 | 173 | # Optional background change 174 | if change_background and not layout_only: 175 | processor.change_background() 176 | if save_background: 177 | background_path = os.path.splitext(save_path)[0] + '_background' + os.path.splitext(save_path)[1] 178 | processor.save_photos(background_path, compress, **file_size_limits) 179 | echo_message('background_saved', path=background_path) 180 | 181 | # Optional resizing 182 | if resize: 183 | processor.resize_image(photo_type) 184 | if save_resized: 185 | resized_path = os.path.splitext(save_path)[0] + '_resized' + os.path.splitext(save_path)[1] 186 | processor.save_photos(resized_path, compress, **file_size_limits) 187 | echo_message('resized_saved', path=resized_path) 188 | 189 | # Generate photo sheet 190 | # Set photo sheet size 191 | sheet_info = photo_requirements.get_resize_image_list(photo_sheet_size) 192 | sheet_width, sheet_height, sheet_resolution = sheet_info['width'], sheet_info['height'], sheet_info['resolution'] 193 | generator = PhotoSheetGenerator((sheet_width, sheet_height), sheet_resolution) 194 | photo_sheet_cv = generator.generate_photo_sheet(processor.photo.image, sheet_rows, sheet_cols, rotate, add_crop_lines) 195 | sheet_path = os.path.splitext(save_path)[0] + '_sheet' + os.path.splitext(save_path)[1] 196 | generator.save_photo_sheet(photo_sheet_cv, sheet_path) 197 | echo_message('sheet_saved', path=sheet_path) 198 | 199 | 200 | if __name__ == "__main__": 201 | cli() 202 | -------------------------------------------------------------------------------- /src/model/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /src/tool/ConfigManager.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import os 3 | import math 4 | from typing import Dict, List, Optional, Tuple 5 | 6 | class ConfigManager: 7 | _instance = None 8 | 9 | def __new__(cls, language: str = 'zh', size_file: Optional[str] = None, color_file: Optional[str] = None): 10 | if cls._instance is None: 11 | cls._instance = super(ConfigManager, cls).__new__(cls) 12 | return cls._instance 13 | 14 | def __init__(self, language: str = 'zh', size_file: Optional[str] = None, color_file: Optional[str] = None): 15 | if not hasattr(self, 'initialized'): 16 | self.language = language 17 | project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) 18 | default_size_file = os.path.join(project_root, 'data', f'size_{language}.csv') 19 | default_color_file = os.path.join(project_root, 'data', f'color_{language}.csv') 20 | self.size_file = os.path.abspath(size_file) if size_file else default_size_file 21 | self.color_file = os.path.abspath(color_file) if color_file else default_color_file 22 | self.size_config: Dict[str, Dict] = {} 23 | self.color_config: Dict[str, Dict] = {} 24 | self.load_configs() 25 | self.initialized = True 26 | 27 | def load_configs(self): 28 | """Load both size and color configurations.""" 29 | self.size_config.clear() 30 | self.color_config.clear() 31 | self.load_size_config() 32 | self.load_color_config() 33 | 34 | def load_size_config(self): 35 | """Load size configuration from CSV file.""" 36 | if not os.path.exists(self.size_file): 37 | raise FileNotFoundError(f"Size configuration file {self.size_file} not found.") 38 | 39 | with open(self.size_file, 'r', encoding='utf-8', newline='') as f: 40 | reader = csv.DictReader(f, quoting=csv.QUOTE_MINIMAL) 41 | for row in reader: 42 | config = {} 43 | for key, value in row.items(): 44 | if key == 'Name': 45 | continue 46 | if value == '': 47 | config[key] = None 48 | elif key in ['PrintWidth', 'PrintHeight']: 49 | config[key] = float(value) if value else None 50 | elif key in ['ElectronicWidth', 'ElectronicHeight', 'Resolution', 'FileSizeMin', 'FileSizeMax']: 51 | config[key] = int(value) if value else None 52 | else: 53 | config[key] = value 54 | self.size_config[row['Name']] = config 55 | 56 | def load_color_config(self): 57 | """Load color configuration from CSV file.""" 58 | if not os.path.exists(self.color_file): 59 | raise FileNotFoundError(f"Color configuration file {self.color_file} not found.") 60 | 61 | with open(self.color_file, 'r', encoding='utf-8', newline='') as f: 62 | reader = csv.DictReader(f, quoting=csv.QUOTE_MINIMAL) 63 | for row in reader: 64 | self.color_config[row['Name']] = { 65 | 'R': int(row['R']), 66 | 'G': int(row['G']), 67 | 'B': int(row['B']), 68 | 'Notes': row['Notes'] 69 | } 70 | 71 | def get_size_config(self, name: str) -> Optional[Dict]: 72 | """Get size configuration by name.""" 73 | return self.size_config.get(name) 74 | 75 | def get_photo_size_configs(self) -> Dict[str, Dict]: 76 | """Get all photo size configurations.""" 77 | return {name: config for name, config in self.size_config.items() if config['Type'] in ['photo', 'both']} 78 | 79 | def get_sheet_size_configs(self) -> Dict[str, Dict]: 80 | """Get all sheet size configurations.""" 81 | return {name: config for name, config in self.size_config.items() if config['Type'] in ['sheet', 'both']} 82 | 83 | def get_color_config(self, name: str) -> Optional[Dict]: 84 | """Get color configuration by name.""" 85 | return self.color_config.get(name) 86 | 87 | def add_size_config(self, name: str, config: Dict): 88 | """Add a new size configuration.""" 89 | self.size_config[name] = config 90 | self.save_size_config() 91 | 92 | def add_color_config(self, name: str, config: Dict): 93 | """Add a new color configuration.""" 94 | self.color_config[name] = config 95 | self.save_color_config() 96 | 97 | def update_size_config(self, name: str, config: Dict): 98 | """Update an existing size configuration.""" 99 | if name in self.size_config: 100 | self.size_config[name].update(config) 101 | self.save_size_config() 102 | else: 103 | raise KeyError(f"Size configuration '{name}' not found.") 104 | 105 | def update_color_config(self, name: str, config: Dict): 106 | """Update an existing color configuration.""" 107 | if name in self.color_config: 108 | self.color_config[name].update(config) 109 | self.save_color_config() 110 | else: 111 | raise KeyError(f"Color configuration '{name}' not found.") 112 | 113 | def delete_size_config(self, name: str): 114 | """Delete a size configuration.""" 115 | if name in self.size_config: 116 | del self.size_config[name] 117 | self.save_size_config() 118 | else: 119 | raise KeyError(f"Size configuration '{name}' not found.") 120 | 121 | def delete_color_config(self, name: str): 122 | """Delete a color configuration.""" 123 | if name in self.color_config: 124 | del self.color_config[name] 125 | self.save_color_config() 126 | else: 127 | raise KeyError(f"Color configuration '{name}' not found.") 128 | 129 | def save_size_config(self): 130 | """Save size configuration to CSV file.""" 131 | with open(self.size_file, 'w', newline='', encoding='utf-8') as f: 132 | fieldnames = ['Name', 'PrintWidth', 'PrintHeight', 'ElectronicWidth', 'ElectronicHeight', 'Resolution', 'FileFormat', 'FileSizeMin', 'FileSizeMax', 'Type', 'Notes'] 133 | writer = csv.DictWriter(f, fieldnames=fieldnames) 134 | writer.writeheader() 135 | for name, config in self.size_config.items(): 136 | row = {'Name': name} 137 | for key, value in config.items(): 138 | if value is None: 139 | row[key] = '' 140 | elif isinstance(value, float): 141 | row[key] = '' if math.isnan(value) else f"{value:g}" 142 | elif key == 'Notes': 143 | row[key] = value.replace(',', '|') 144 | else: 145 | row[key] = value 146 | writer.writerow(row) 147 | 148 | def save_color_config(self): 149 | """Save color configuration to CSV file.""" 150 | with open(self.color_file, 'w', newline='', encoding='utf-8') as f: 151 | fieldnames = ['Name', 'R', 'G', 'B', 'Notes'] 152 | writer = csv.DictWriter(f, fieldnames=fieldnames) 153 | writer.writeheader() 154 | for name, config in self.color_config.items(): 155 | row = {'Name': name, **config} 156 | row['Notes'] = row['Notes'].replace(',', '|') 157 | writer.writerow(row) 158 | 159 | def check_size_config_integrity(self, config: Dict) -> Tuple[bool, str]: 160 | """Check integrity of a size configuration.""" 161 | required_fields = ['ElectronicWidth', 'ElectronicHeight', 'Type'] 162 | for field in required_fields: 163 | if field not in config or config[field] is None: 164 | return False, f"Missing required field: {field}" 165 | return True, "Config is valid" 166 | 167 | def check_color_config_integrity(self, config: Dict) -> Tuple[bool, str]: 168 | """Check integrity of a color configuration.""" 169 | required_fields = ['R', 'G', 'B'] 170 | for field in required_fields: 171 | if field not in config or config[field] is None: 172 | return False, f"Missing required field: {field}" 173 | return True, "Config is valid" 174 | 175 | def switch_language(self, new_language: str): 176 | """Switch language and reload configurations.""" 177 | self.language = new_language 178 | project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) 179 | self.size_file = os.path.join(project_root, 'data', f'size_{new_language}.csv') 180 | self.color_file = os.path.join(project_root, 'data', f'color_{new_language}.csv') 181 | try: 182 | self.load_configs() 183 | except FileNotFoundError as e: 184 | print(f"Error loading configuration files: {e}") 185 | except Exception as e: 186 | print(f"An unexpected error occurred: {e}") 187 | 188 | def get_photo_sizes(self) -> Dict[str, Dict]: 189 | """Get all photo sizes.""" 190 | return {name: config for name, config in self.size_config.items() if config['Type'] in ['photo', 'both']} 191 | 192 | def get_sheet_sizes(self) -> Dict[str, Dict]: 193 | """Get all sheet sizes.""" 194 | return {name: config for name, config in self.size_config.items() if config['Type'] in ['sheet', 'both']} 195 | 196 | def list_size_configs(self) -> List[str]: 197 | """List all size configuration names.""" 198 | return list(self.size_config.keys()) 199 | 200 | def list_color_configs(self) -> List[str]: 201 | """List all color configuration names.""" 202 | return list(self.color_config.keys()) 203 | -------------------------------------------------------------------------------- /src/tool/ImageProcessor.py: -------------------------------------------------------------------------------- 1 | import os 2 | import warnings 3 | from io import BytesIO 4 | 5 | import cv2 as cv 6 | import numpy as np 7 | import piexif 8 | from PIL import Image 9 | 10 | from ImageSegmentation import ImageSegmentation 11 | from PhotoEntity import PhotoEntity 12 | from PhotoRequirements import PhotoRequirements 13 | from agpic import ImageCompressor 14 | 15 | 16 | def get_model_file(filename): 17 | return os.path.join('model', filename) 18 | 19 | 20 | class ImageProcessor: 21 | """ 22 | Image processing class for cropping and correcting the human region in images. 23 | """ 24 | 25 | def __init__(self, img_path, 26 | yolov8_model_path=get_model_file('yolov8n-pose.onnx'), 27 | yunet_model_path=get_model_file('face_detection_yunet_2023mar.onnx'), 28 | RMBG_model_path=get_model_file('RMBG-1.4-model.onnx'), 29 | rgb_list=None, 30 | y_b=False): 31 | """ 32 | Initialize ImageProcessor instance 33 | 34 | :param img_path: Path to the image 35 | :param yolov8_model_path: Path to the YOLOv8 model 36 | :param yunet_model_path: Path to the YuNet model 37 | :param RMBG_model_path: Path to the RMBG model 38 | :param rgb_list: List of rgb channel values for image composition 39 | """ 40 | if not os.path.exists(img_path): 41 | raise FileNotFoundError(f"Image path does not exist: {img_path}") 42 | if not os.path.exists(yolov8_model_path): 43 | raise FileNotFoundError(f"YOLOv8 model path does not exist: {yolov8_model_path}") 44 | if not os.path.exists(yunet_model_path): 45 | raise FileNotFoundError(f"YuNet model path does not exist: {yunet_model_path}") 46 | if not os.path.exists(RMBG_model_path): 47 | raise FileNotFoundError(f"RMBG model path does not exist: {RMBG_model_path}") 48 | 49 | self.photo = PhotoEntity(img_path, yolov8_model_path, yunet_model_path, y_b) 50 | self.segmentation = ImageSegmentation(model_path=RMBG_model_path, model_input_size=[1024, 1024], 51 | rgb_list=rgb_list if rgb_list is not None else [255, 255, 255]) 52 | self.photo_requirements_detector = PhotoRequirements() 53 | 54 | @staticmethod 55 | def rotate_image(image: np.ndarray, angle: float) -> np.ndarray: 56 | """ 57 | Rotate the image 58 | 59 | :param image: Original image (numpy.ndarray) 60 | :param angle: Rotation angle (degrees) 61 | :return: Rotated image (numpy.ndarray) 62 | """ 63 | if not isinstance(image, np.ndarray): 64 | raise TypeError("The input image must be of type numpy.ndarray") 65 | if not isinstance(angle, (int, float)): 66 | raise TypeError("The rotation angle must be of type int or float") 67 | 68 | height, width = image.shape[:2] 69 | center = (width / 2, height / 2) 70 | matrix = cv.getRotationMatrix2D(center, angle, 1.0) 71 | rotated_image = cv.warpAffine(image, matrix, (width, height), flags=cv.INTER_CUBIC) 72 | return rotated_image 73 | 74 | @staticmethod 75 | def compute_rotation_angle(left_shoulder: tuple, right_shoulder: tuple, image_shape: tuple) -> float: 76 | """ 77 | Compute the rotation angle to align the shoulders horizontally 78 | 79 | :param left_shoulder: Coordinates of the left shoulder keypoint (normalized or pixel coordinates) 80 | :param right_shoulder: Coordinates of the right shoulder keypoint (normalized or pixel coordinates) 81 | :param image_shape: Height and width of the image 82 | :return: Rotation angle (degrees) 83 | :rtype: float 84 | """ 85 | if not (isinstance(left_shoulder, tuple) and len(left_shoulder) == 3): 86 | raise ValueError("The left shoulder keypoint format is incorrect") 87 | if not (isinstance(right_shoulder, tuple) and len(right_shoulder) == 3): 88 | raise ValueError("The right shoulder keypoint format is incorrect") 89 | if not (isinstance(image_shape, tuple) and len(image_shape) == 2): 90 | raise ValueError("The image size format is incorrect") 91 | 92 | height, width = image_shape 93 | 94 | # If coordinates are normalized, convert to pixel coordinates 95 | if left_shoulder[2] < 1.0 and right_shoulder[2] < 1.0: 96 | left_shoulder = (left_shoulder[0] * width, left_shoulder[1] * height) 97 | right_shoulder = (right_shoulder[0] * width, right_shoulder[1] * height) 98 | 99 | dx = right_shoulder[0] - left_shoulder[0] 100 | dy = right_shoulder[1] - left_shoulder[1] 101 | angle = np.arctan2(dy, dx) * (180 / np.pi) # Compute the angle 102 | return angle 103 | 104 | def crop_and_correct_image(self) -> PhotoEntity: 105 | """ 106 | Crop and correct the human region in the image 107 | 108 | :return: Updated PhotoEntity instance 109 | :rtype: PhotoEntity 110 | :raises ValueError: If no single person is detected 111 | """ 112 | if self.photo.person_bbox is not None: 113 | height, width = self.photo.image.shape[:2] 114 | 115 | # Get bounding box coordinates and keypoints 116 | bbox_xyxy = self.photo.person_bbox 117 | x1, y1, x2, y2 = bbox_xyxy 118 | bbox_keypoints = self.photo.person_keypoints 119 | bbox_height = y2 - y1 120 | 121 | # Get shoulder keypoints 122 | left_shoulder = (bbox_keypoints[18], bbox_keypoints[19], 123 | bbox_keypoints[20]) # bbox_keypoints[5] right shoulder 124 | right_shoulder = (bbox_keypoints[15], bbox_keypoints[16], bbox_keypoints[17]) # bbox_keypoints[6] left shoulder 125 | # print(left_shoulder, right_shoulder) 126 | 127 | # Compute rotation angle 128 | angle = self.compute_rotation_angle(left_shoulder, right_shoulder, (height, width)) 129 | 130 | # Rotate the image 131 | rotated_image = self.rotate_image(self.photo.image, angle) if abs(angle) > 5 else self.photo.image 132 | 133 | # Recalculate crop box position in the rotated image 134 | height, width = rotated_image.shape[:2] 135 | x1, y1, x2, y2 = int(x1 * width / width), int(y1 * height / height), int(x2 * width / width), int( 136 | y2 * height / height) 137 | 138 | # Adjust crop area to ensure the top does not exceed the image range 139 | top_margin = bbox_height / 5 140 | y1 = max(int(y1), 0) if y1 >= top_margin else 0 141 | 142 | # If y1 is less than 60 pixels from the top of the face detection box, adjust it 143 | if y1 != 0 and self.photo.face_bbox is not None: 144 | if int(y1) - int(self.photo.face_bbox[1]) < max(int(height / 600 * 60), 60): 145 | y1 = max(int(y1 - (int(height / 600 * 60))), 0) 146 | 147 | # Adjust the crop area to ensure the lower body is not too long 148 | shoulder_margin = y1 + bbox_height / max(int(height / 600 * 16), 16) 149 | y2 = min(y2, height - int(shoulder_margin)) if left_shoulder[1] > shoulder_margin or right_shoulder[ 150 | 1] > shoulder_margin else y2 151 | 152 | # Adjust the crop area to ensure the face is centered in the image 153 | left_eye = [bbox_keypoints[6], bbox_keypoints[7], bbox_keypoints[8]] # bbox_keypoints[2] 154 | right_eye = [bbox_keypoints[3], bbox_keypoints[4], bbox_keypoints[5]] # bbox_keypoints[1] 155 | # print(left_eye, right_eye) 156 | face_center_x = (left_eye[0] + right_eye[0]) / 2 157 | crop_width = x2 - x1 158 | 159 | x1 = max(int(face_center_x - crop_width / 2), 0) 160 | x2 = min(int(face_center_x + crop_width / 2), width) 161 | 162 | # Ensure the crop area does not exceed the image range 163 | x1 = 0 if x1 < 0 else x1 164 | x2 = width if x2 > width else x2 165 | 166 | # print(x1,x2,y1,y2) 167 | 168 | # Crop the image 169 | cropped_image = rotated_image[y1:y2, x1:x2] 170 | 171 | # Update the PhotoEntity object's image and re-detect 172 | self.photo.image = cropped_image 173 | self.photo.detect() 174 | # Manually set the person bounding box to the full image range 175 | self.photo.person_bbox = [0, 0, cropped_image.shape[1], cropped_image.shape[0]] 176 | return self.photo 177 | else: 178 | warnings.warn("No human face detected. Falling back to general object detection.", UserWarning) 179 | # No human subject detected, use YOLOv8 for basic object detection 180 | yolo_result, _ = self.photo.yolov8_detector.detect(self.photo.img_path) 181 | if yolo_result and yolo_result['boxes']: 182 | warnings.warn("Object detected. Using the first detected object for processing.", UserWarning) 183 | # Use the first detected object's bounding box 184 | bbox = yolo_result['boxes'][0] 185 | x1, y1, x2, y2 = map(int, bbox) 186 | 187 | # Adjust crop area to include some margin 188 | height, width = self.photo.image.shape[:2] 189 | margin = min(height, width) // 10 190 | x1 = max(0, x1 - margin) 191 | y1 = max(0, y1 - margin) 192 | x2 = min(width, x2 + margin) 193 | y2 = min(height, y2 + margin) 194 | 195 | # Crop the image 196 | cropped_image = self.photo.image[y1:y2, x1:x2] 197 | 198 | # Update the PhotoEntity object's image and re-detect 199 | self.photo.image = cropped_image 200 | self.photo.detect() 201 | 202 | # Set the bounding box to the full image range 203 | self.photo.person_bbox = [0, 0, cropped_image.shape[1], cropped_image.shape[0]] 204 | else: 205 | warnings.warn("No object detected. Using the entire image.", UserWarning) 206 | # If no object is detected, use the entire image 207 | self.photo.person_bbox = [0, 0, self.photo.image.shape[1], self.photo.image.shape[0]] 208 | 209 | return self.photo 210 | 211 | def change_background(self, rgb_list=None) -> PhotoEntity: 212 | """ 213 | Replace the background of the human region in the image 214 | 215 | :param rgb_list: New list of RGB channel values 216 | :return: Updated PhotoEntity instance 217 | :rtype: PhotoEntity 218 | """ 219 | if rgb_list is not None: 220 | if not (isinstance(rgb_list, (list, tuple)) and len(rgb_list) == 3): 221 | raise ValueError("The RGB value format is incorrect") 222 | self.segmentation.rgb_list = tuple(rgb_list) 223 | 224 | self.photo.image = self.segmentation.infer(self.photo.image) 225 | return self.photo 226 | 227 | def resize_image(self, photo_type): 228 | # Get the target dimensions and other info 229 | photo_info = self.photo_requirements_detector.get_resize_image_list(photo_type) 230 | width, height = photo_info['width'], photo_info['height'] 231 | 232 | # Get the original image dimensions 233 | orig_height, orig_width = self.photo.image.shape[:2] 234 | 235 | # Check if the dimensions are integer multiples 236 | is_width_multiple = (orig_width % width == 0) if orig_width >= width else (width % orig_width == 0) 237 | is_height_multiple = (orig_height % height == 0) if orig_height >= height else (height % orig_height == 0) 238 | 239 | if is_width_multiple and is_height_multiple: 240 | # Resize the image proportionally 241 | self.photo.image = cv.resize(self.photo.image, (width, height), interpolation=cv.INTER_AREA) 242 | return self.photo.image 243 | 244 | def get_crop_coordinates(original_size, aspect_ratio): 245 | original_width, original_height = original_size 246 | crop_width = original_width 247 | crop_height = int(crop_width / aspect_ratio) 248 | if crop_height > original_height: 249 | crop_height = original_height 250 | crop_width = int(crop_height * aspect_ratio) 251 | x_start = (original_width - crop_width) // 2 252 | y_start = 0 253 | return x_start, x_start + crop_width, y_start, y_start + crop_height 254 | 255 | x1, x2, y1, y2 = get_crop_coordinates((orig_width, orig_height), width / height) 256 | cropped_image = self.photo.image[y1:y2, x1:x2] 257 | 258 | # Update the PhotoEntity object's image 259 | self.photo.image = cropped_image 260 | 261 | # Resize the image proportionally 262 | self.photo.image = cv.resize(self.photo.image, (width, height), interpolation=cv.INTER_AREA) 263 | 264 | # Store the actual print size and resolution in the PhotoEntity 265 | self.photo.print_size = photo_info['print_size'] 266 | self.photo.resolution = photo_info['resolution'] 267 | 268 | return self.photo.image 269 | 270 | def save_photos(self, save_path: str, y_b=False, target_size=None, size_range=None) -> None: 271 | """ 272 | Save the image to the specified path. 273 | :param save_path: The path to save the image 274 | :param y_b: Whether to compress the image 275 | :param target_size: Target file size in KB. When specified, ignores quality. 276 | :param size_range: A tuple of (min_size, max_size) in KB for the output file. 277 | """ 278 | # Parameter validation for target_size and size_range 279 | if target_size is not None and size_range is not None: 280 | warnings.warn("Both target_size and size_range provided. Using target_size and ignoring size_range.", 281 | UserWarning) 282 | size_range = None 283 | 284 | if target_size is not None and target_size <= 0: 285 | raise ValueError(f"Target size must be greater than 0, got {target_size}") 286 | 287 | if size_range is not None: 288 | if len(size_range) != 2: 289 | raise ValueError(f"Size range must be a tuple of (min_size, max_size), got {size_range}") 290 | min_size, max_size = size_range 291 | if min_size <= 0 or max_size <= 0: 292 | raise ValueError(f"Size range values must be greater than 0, got min_size={min_size}, max_size={max_size}") 293 | if min_size >= max_size: 294 | raise ValueError(f"Minimum size must be less than maximum size, got min_size={min_size}, max_size={max_size}") 295 | 296 | # Check the path length 297 | max_path_length = 200 298 | if len(save_path) > max_path_length: 299 | # Intercepts the filename and keeps the rest of the path 300 | dir_name = os.path.dirname(save_path) 301 | base_name = os.path.basename(save_path) 302 | ext = os.path.splitext(base_name)[1] 303 | base_name = base_name[:200] + ext # Ensure that filenames do not exceed 200 characters 304 | save_path = os.path.join(dir_name, base_name) 305 | 306 | # Get the DPI from the photo entity 307 | if isinstance(self.photo.resolution, int): 308 | dpi = self.photo.resolution 309 | elif isinstance(self.photo.resolution, str): 310 | dpi = int(self.photo.resolution.replace('dpi', '')) 311 | else: 312 | dpi = 300 # Default DPI if resolution is not set 313 | 314 | # Check if we need to compress (either y_b flag is True or size parameters are provided) 315 | need_compression = y_b or target_size is not None or size_range is not None 316 | 317 | if need_compression: 318 | buffer = BytesIO() 319 | pil_image = Image.fromarray(cv.cvtColor(self.photo.image, cv.COLOR_BGR2RGB)) 320 | pil_image.save(buffer, format="JPEG") 321 | image_bytes = buffer.getvalue() 322 | 323 | try: 324 | if target_size is not None: 325 | compressed_bytes = ImageCompressor.compress_image_from_bytes( 326 | image_bytes, quality=85, target_size=target_size 327 | ) 328 | elif size_range is not None: 329 | compressed_bytes = ImageCompressor.compress_image_from_bytes( 330 | image_bytes, quality=85, size_range=size_range 331 | ) 332 | else: 333 | compressed_bytes = ImageCompressor.compress_image_from_bytes( 334 | image_bytes, quality=85 335 | ) 336 | 337 | # Write compressed bytes directly to the file 338 | with open(save_path, 'wb') as f: 339 | f.write(compressed_bytes) 340 | 341 | # Setting up DPI using piexif 342 | try: 343 | # Converting DPI to EXIF resolution format 344 | x_resolution = (dpi, 1) # DPI value and unit 345 | y_resolution = (dpi, 1) 346 | resolution_unit = 2 # inches 347 | 348 | # Read existing EXIF data (if present) 349 | exif_dict = piexif.load(save_path) 350 | 351 | # If '0th' does not exist, create it 352 | if '0th' not in exif_dict: 353 | exif_dict['0th'] = {} 354 | 355 | # Set resolution information 356 | exif_dict['0th'][piexif.ImageIFD.XResolution] = x_resolution 357 | exif_dict['0th'][piexif.ImageIFD.YResolution] = y_resolution 358 | exif_dict['0th'][piexif.ImageIFD.ResolutionUnit] = resolution_unit 359 | 360 | # Write EXIF data back to file 361 | exif_bytes = piexif.dump(exif_dict) 362 | piexif.insert(exif_bytes, save_path) 363 | except Exception as e: 364 | warnings.warn(f"Failed to set DPI with piexif: {str(e)}. Image saved without DPI metadata.", UserWarning) 365 | 366 | except Exception as e: 367 | warnings.warn(f"Image compression failed: {str(e)}. Saving uncompressed image.", UserWarning) 368 | _, img_bytes = cv.imencode('.jpg', self.photo.image, [cv.IMWRITE_JPEG_QUALITY, 95]) 369 | with open(save_path, 'wb') as f: 370 | f.write(img_bytes) 371 | 372 | try: 373 | x_resolution = (dpi, 1) 374 | y_resolution = (dpi, 1) 375 | resolution_unit = 2 376 | 377 | exif_dict = piexif.load(save_path) 378 | 379 | if '0th' not in exif_dict: 380 | exif_dict['0th'] = {} 381 | 382 | exif_dict['0th'][piexif.ImageIFD.XResolution] = x_resolution 383 | exif_dict['0th'][piexif.ImageIFD.YResolution] = y_resolution 384 | exif_dict['0th'][piexif.ImageIFD.ResolutionUnit] = resolution_unit 385 | 386 | exif_bytes = piexif.dump(exif_dict) 387 | piexif.insert(exif_bytes, save_path) 388 | except Exception as ex: 389 | warnings.warn(f"Failed to set DPI with piexif: {str(ex)}. Image saved without DPI metadata.", UserWarning) 390 | else: 391 | _, img_bytes = cv.imencode('.jpg', self.photo.image, [cv.IMWRITE_JPEG_QUALITY, 95]) 392 | with open(save_path, 'wb') as f: 393 | f.write(img_bytes) 394 | 395 | try: 396 | x_resolution = (dpi, 1) 397 | y_resolution = (dpi, 1) 398 | resolution_unit = 2 399 | 400 | exif_dict = piexif.load(save_path) 401 | 402 | if '0th' not in exif_dict: 403 | exif_dict['0th'] = {} 404 | 405 | exif_dict['0th'][piexif.ImageIFD.XResolution] = x_resolution 406 | exif_dict['0th'][piexif.ImageIFD.YResolution] = y_resolution 407 | exif_dict['0th'][piexif.ImageIFD.ResolutionUnit] = resolution_unit 408 | 409 | exif_bytes = piexif.dump(exif_dict) 410 | piexif.insert(exif_bytes, save_path) 411 | except Exception as ex: 412 | warnings.warn(f"Failed to set DPI with piexif: {str(ex)}. Image saved without DPI metadata.", UserWarning) 413 | 414 | -------------------------------------------------------------------------------- /src/tool/ImageSegmentation.py: -------------------------------------------------------------------------------- 1 | import onnxruntime as ort 2 | import numpy as np 3 | from PIL import Image 4 | import cv2 5 | 6 | def rgb_to_rgba(rgb): 7 | if not isinstance(rgb, (list, tuple)) or len(rgb) != 3: 8 | raise ValueError("rgb must be a list or tuple containing three elements") 9 | if any(not (0 <= color <= 255) for color in rgb): 10 | raise ValueError("rgb values must be in the range [0, 255]") 11 | 12 | red, green, blue = rgb 13 | 14 | # Add Alpha channel (fully opaque) 15 | alpha = 255 16 | 17 | # Return RGBA values 18 | return (red, green, blue, alpha) 19 | 20 | 21 | class ImageSegmentation: 22 | def __init__(self, model_path: str, model_input_size: list, rgb_list: list): 23 | if not isinstance(model_path, str) or not model_path.endswith('.onnx'): 24 | raise ValueError("model_path must be a valid ONNX model file path") 25 | if not isinstance(model_input_size, list) or len(model_input_size) != 2: 26 | raise ValueError("model_input_size must be a list with two elements") 27 | if any(not isinstance(size, int) or size <= 0 for size in model_input_size): 28 | raise ValueError("model_input_size elements must be positive integers") 29 | 30 | # Initialize model path and input size 31 | self.model_path = model_path 32 | self.model_input_size = model_input_size 33 | try: 34 | self.ort_session = ort.InferenceSession(model_path) 35 | except Exception as e: 36 | raise RuntimeError(f"Failed to load ONNX model: {e}") 37 | 38 | self.rgb_list = rgb_to_rgba(rgb_list) 39 | 40 | def preprocess_image(self, im: np.ndarray) -> np.ndarray: 41 | # If the image is grayscale, add a dimension to make it a color image 42 | if len(im.shape) < 3: 43 | im = im[:, :, np.newaxis] 44 | # Resize the image to match the model input size 45 | try: 46 | im_resized = np.array(Image.fromarray(im).resize(self.model_input_size, Image.BILINEAR)) 47 | except Exception as e: 48 | raise RuntimeError(f"Error resizing image: {e}") 49 | # Normalize image pixel values to the [0, 1] range 50 | image = im_resized.astype(np.float32) / 255.0 51 | # Further normalize image data 52 | mean = np.array([0.5, 0.5, 0.5], dtype=np.float32) 53 | std = np.array([1.0, 1.0, 1.0], dtype=np.float32) 54 | image = (image - mean) / std 55 | # Convert the image to the required shape 56 | image = image.transpose(2, 0, 1) # Change dimension order (channels, height, width) 57 | return np.expand_dims(image, axis=0) # Add batch dimension 58 | 59 | def postprocess_image(self, result: np.ndarray, im_size: list) -> np.ndarray: 60 | # Resize the result image to match the original image size 61 | result = np.squeeze(result) 62 | try: 63 | result = np.array(Image.fromarray(result).resize(im_size, Image.BILINEAR)) 64 | except Exception as e: 65 | raise RuntimeError(f"Error resizing result image: {e}") 66 | # Normalize the result image data 67 | ma = result.max() 68 | mi = result.min() 69 | result = (result - mi) / (ma - mi) 70 | # Convert to uint8 image 71 | im_array = (result * 255).astype(np.uint8) 72 | return im_array 73 | 74 | def infer(self, image: np.ndarray) -> np.ndarray: 75 | # Prepare the input image 76 | orig_im_size = image.shape[0:2] 77 | 78 | # Convert OpenCV image to PIL image 79 | image_pil = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB)) 80 | 81 | image_preprocessed = self.preprocess_image(np.array(image_pil)) 82 | 83 | # Perform inference (image segmentation) 84 | ort_inputs = {self.ort_session.get_inputs()[0].name: image_preprocessed} 85 | try: 86 | ort_outs = self.ort_session.run(None, ort_inputs) 87 | except Exception as e: 88 | raise RuntimeError(f"ONNX inference failed: {e}") 89 | result = ort_outs[0] 90 | 91 | # Post-process the result image 92 | result_image = self.postprocess_image(result, orig_im_size) 93 | 94 | # Save the result image 95 | try: 96 | pil_im = Image.fromarray(result_image).convert("L") 97 | orig_image = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGBA)) 98 | pil_im = pil_im.resize(orig_image.size) 99 | except Exception as e: 100 | raise RuntimeError(f"Error processing images: {e}") 101 | 102 | no_bg_image = Image.new("RGBA", orig_image.size, self.rgb_list) 103 | 104 | # Paste the original image using the mask 105 | no_bg_image.paste(orig_image, mask=pil_im) 106 | 107 | # Convert to OpenCV image (BGRA) 108 | no_bg_image_cv = cv2.cvtColor(np.array(no_bg_image), cv2.COLOR_RGBA2BGRA) 109 | 110 | return no_bg_image_cv 111 | -------------------------------------------------------------------------------- /src/tool/PhotoEntity.py: -------------------------------------------------------------------------------- 1 | import os 2 | import cv2 as cv 3 | from PIL import Image, ExifTags 4 | import numpy as np 5 | 6 | from yolov8_detector import YOLOv8Detector 7 | from YuNet import FaceDetector 8 | from agpic import ImageCompressor 9 | 10 | 11 | class PhotoEntity: 12 | def __init__(self, img_path, yolov8_model_path, yunet_model_path, y_b=False): 13 | """ 14 | Initialize the PhotoEntity class. 15 | 16 | :param img_path: Path to the image 17 | :param yolov8_model_path: Path to the YOLOv8 model 18 | :param yunet_model_path: Path to the YuNet model 19 | :param y_b: Whether to compress the image, defaults to False 20 | """ 21 | self.img_path = img_path 22 | self.image = self._correct_image_orientation(img_path) 23 | self.yolov8_detector = YOLOv8Detector(yolov8_model_path) 24 | self.face_detector = FaceDetector(yunet_model_path) 25 | self.ImageCompressor_detector = ImageCompressor() 26 | if y_b: 27 | self._compress_image() 28 | 29 | # Initialize detection result attributes 30 | self.person_bbox = None 31 | self.person_label = None 32 | self.person_keypoints = None 33 | self.person_width = None 34 | self.person_height = None 35 | self.face_bbox = None 36 | self.face_width = None 37 | self.face_height = None 38 | self.print_size = None 39 | self.resolution = None 40 | self.detect() 41 | 42 | def _correct_image_orientation(self, image_path): 43 | # Open the image and read EXIF information 44 | image = Image.open(image_path) 45 | try: 46 | exif = image._getexif() 47 | if exif is not None: 48 | # Get EXIF tags 49 | for tag, value in exif.items(): 50 | if tag in ExifTags.TAGS: 51 | if ExifTags.TAGS[tag] == 'Orientation': 52 | orientation = value 53 | # Adjust the image based on orientation 54 | if orientation == 3: 55 | image = image.rotate(180, expand=True) 56 | elif orientation == 6: 57 | image = image.rotate(270, expand=True) 58 | elif orientation == 8: 59 | image = image.rotate(90, expand=True) 60 | except (AttributeError, KeyError, IndexError) as e: 61 | raise e 62 | 63 | # Convert Pillow image object to OpenCV image object 64 | image_np = np.array(image) 65 | # OpenCV defaults to BGR format, so convert to RGB 66 | image_np = cv.cvtColor(image_np, cv.COLOR_RGB2BGR) 67 | 68 | return image_np 69 | 70 | def _compress_image(self): 71 | """ 72 | Compress the image to reduce memory usage. 73 | """ 74 | ext = os.path.splitext(self.img_path)[1].lower() 75 | encode_format = '.jpg' if ext in ['.jpg', '.jpeg'] else '.png' 76 | 77 | # Convert OpenCV image to byte format 78 | is_success, buffer = cv.imencode(encode_format, self.image) 79 | if not is_success: 80 | raise ValueError("Failed to encode the image to byte format") 81 | 82 | image_bytes = buffer.tobytes() 83 | 84 | # Call compress_image_from_bytes function to compress the image 85 | compressed_bytes = self.ImageCompressor_detector.compress_image_from_bytes(image_bytes) 86 | 87 | # Convert the compressed bytes back to OpenCV image format 88 | self.image = cv.imdecode(np.frombuffer(compressed_bytes, np.uint8), cv.IMREAD_COLOR) 89 | 90 | def get_print_info(self): 91 | """Get the print size and resolution information.""" 92 | return { 93 | 'print_size': self.print_size, 94 | 'resolution': self.resolution 95 | } 96 | 97 | def detect(self, detect_person=True, detect_face=True): 98 | """ 99 | Detect persons and faces in the image. 100 | 101 | :param detect_person: Whether to detect persons, defaults to True 102 | :param detect_face: Whether to detect faces, defaults to True 103 | """ 104 | if detect_person: 105 | self.detect_person() 106 | if detect_face: 107 | self.detect_face() 108 | 109 | def detect_person(self): 110 | """ 111 | Detect persons in the image. 112 | """ 113 | person_result, original_img = self.yolov8_detector.detect_person(self.img_path) 114 | if person_result: 115 | self.person_bbox = person_result['bbox_xyxy'] 116 | self.person_label = person_result['bbox_label'] 117 | self.person_keypoints = person_result['bbox_keypoints'] 118 | self.person_width = self.person_bbox[2] - self.person_bbox[0] 119 | self.person_height = self.person_bbox[3] - self.person_bbox[1] 120 | else: 121 | self._reset_person_data() 122 | 123 | def detect_face(self): 124 | """ 125 | Detect faces in the image. 126 | """ 127 | face_results = self.face_detector.process_image(self.img_path) 128 | if not (face_results is None) and len(face_results) > 0: 129 | self.face_bbox = face_results[0][:4].astype('uint32') 130 | self.face_width = int(self.face_bbox[2]) - int(self.face_bbox[0]) 131 | self.face_height = int(self.face_bbox[3]) - int(self.face_bbox[1]) 132 | else: 133 | self._reset_face_data() 134 | 135 | def _reset_person_data(self): 136 | """ 137 | Reset person detection data. 138 | """ 139 | self.person_bbox = None 140 | self.person_label = None 141 | self.person_keypoints = None 142 | self.person_width = None 143 | self.person_height = None 144 | 145 | def _reset_face_data(self): 146 | """ 147 | Reset face detection data. 148 | """ 149 | self.face_bbox = None 150 | self.face_width = None 151 | self.face_height = None 152 | 153 | def set_img_path(self, img_path): 154 | """ 155 | Set the image path and re-detect. 156 | 157 | :param img_path: New image path 158 | """ 159 | self.img_path = img_path 160 | self.image = cv.imdecode(np.fromfile(img_path, dtype=np.uint8), cv.IMREAD_COLOR) 161 | self.detect() 162 | 163 | def set_yolov8_model_path(self, model_path): 164 | """ 165 | Set the YOLOv8 model path and re-detect. 166 | 167 | :param model_path: New YOLOv8 model path 168 | """ 169 | self.yolov8_detector = YOLOv8Detector(model_path) 170 | self.detect() 171 | 172 | def set_yunet_model_path(self, model_path): 173 | """ 174 | Set the YuNet model path and re-detect. 175 | 176 | :param model_path: New YuNet model path 177 | """ 178 | self.face_detector = FaceDetector(model_path) 179 | self.detect() 180 | 181 | def manually_set_person_data(self, bbox, label, keypoints): 182 | """ 183 | Manually set person detection data. 184 | 185 | :param bbox: Person bounding box 186 | :param label: Person label 187 | :param keypoints: Person keypoints 188 | """ 189 | self.person_bbox = bbox 190 | self.person_label = label 191 | self.person_keypoints = keypoints 192 | self.person_width = self.person_bbox[2] - self.person_bbox[0] 193 | self.person_height = self.person_bbox[3] - self.person_bbox[1] 194 | 195 | def manually_set_face_data(self, bbox): 196 | """ 197 | Manually set face detection data. 198 | 199 | :param bbox: Face bounding box 200 | """ 201 | self.face_bbox = bbox 202 | self.face_width = self.face_bbox[2] - self.face_bbox[0] 203 | self.face_height = self.face_bbox[3] - self.face_bbox[1] 204 | -------------------------------------------------------------------------------- /src/tool/PhotoRequirements.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from ConfigManager import ConfigManager 4 | 5 | class PhotoRequirements: 6 | def __init__(self, language=None, size_file=None, color_file=None): 7 | if language is None: 8 | language = os.getenv('CLI_LANGUAGE', 'zh') 9 | self.config_manager = ConfigManager(language, size_file, color_file) 10 | 11 | def get_requirements(self, photo_type): 12 | if not isinstance(photo_type, str): 13 | raise TypeError("Photo_type must be a string.") 14 | 15 | requirements = self.config_manager.get_size_config(photo_type) 16 | if requirements: 17 | return { 18 | 'print_size': f"{requirements['PrintWidth']}cm x {requirements['PrintHeight']}cm" if requirements['PrintWidth'] and requirements['PrintHeight'] else 'N/A', 19 | 'electronic_size': f"{requirements['ElectronicWidth']}px x {requirements['ElectronicHeight']}px", 20 | 'resolution': f"{requirements['Resolution']}dpi" if requirements['Resolution'] else 'N/A', 21 | 'file_format': requirements['FileFormat'], 22 | 'file_size': f"{requirements['FileSizeMin']}-{requirements['FileSizeMax']}KB" if requirements['FileSizeMin'] and requirements['FileSizeMax'] else 'N/A' 23 | } 24 | else: 25 | return None 26 | 27 | def list_photo_types(self): 28 | return self.config_manager.list_size_configs() 29 | 30 | def get_resize_image_list(self, photo_type): 31 | requirements = self.config_manager.get_size_config(photo_type) 32 | if not requirements: 33 | raise ValueError(f"Photo type '{photo_type}' does not exist in size configurations.") 34 | 35 | # Get electronic size 36 | electronic_width = requirements['ElectronicWidth'] 37 | electronic_height = requirements['ElectronicHeight'] 38 | 39 | # Get print size and convert to inches if provided 40 | print_width_cm = requirements.get('PrintWidth') 41 | print_height_cm = requirements.get('PrintHeight') 42 | print_width_inch = print_width_cm / 2.54 if print_width_cm else None 43 | print_height_inch = print_height_cm / 2.54 if print_height_cm else None 44 | 45 | # Get resolution (DPI) 46 | resolution = requirements.get('Resolution', 300) # Default to 300 DPI if not specified 47 | 48 | # Calculate pixel dimensions based on print size and resolution 49 | if print_width_inch and print_height_inch: 50 | calc_width = int(print_width_inch * resolution) 51 | calc_height = int(print_height_inch * resolution) 52 | 53 | # Use the larger of calculated or electronic size 54 | width = max(calc_width, electronic_width) 55 | height = max(calc_height, electronic_height) 56 | else: 57 | # If print size is not provided, use electronic size 58 | width = electronic_width 59 | height = electronic_height 60 | 61 | # Calculate actual print size based on final pixel dimensions 62 | actual_print_width_cm = (width / resolution) * 2.54 63 | actual_print_height_cm = (height / resolution) * 2.54 64 | 65 | return { 66 | 'width': width, 67 | 'height': height, 68 | 'resolution': resolution, 69 | 'file_format': requirements['FileFormat'], 70 | 'electronic_size': f"{width}px x {height}px", 71 | 'print_size': f"{actual_print_width_cm:.2f}cm x {actual_print_height_cm:.2f}cm" 72 | } 73 | 74 | def get_file_size_limits(self, photo_type): 75 | """ 76 | Get file size limits for a specific photo type. 77 | 78 | :param photo_type: Photo type name 79 | :type photo_type: str 80 | :return: Dictionary with target_size or size_range parameters 81 | :rtype: dict 82 | """ 83 | requirements = self.config_manager.get_size_config(photo_type) 84 | if not requirements: 85 | return {} 86 | 87 | # Get file size limits from requirements 88 | min_size = requirements.get('FileSizeMin') 89 | max_size = requirements.get('FileSizeMax') 90 | 91 | # Return appropriate compression parameters based on what's available 92 | if min_size is not None and max_size is not None: 93 | # If both min and max are defined, use size_range 94 | return {'size_range': (min_size, max_size)} 95 | elif min_size is not None: 96 | # If only min is defined, use it as target_size 97 | return {'target_size': min_size} 98 | elif max_size is not None: 99 | # If only max is defined, use it as target_size 100 | return {'target_size': max_size} 101 | else: 102 | # If neither is defined, return empty dict 103 | return {} 104 | 105 | def switch_language(self, new_language): 106 | self.config_manager.switch_language(new_language) 107 | -------------------------------------------------------------------------------- /src/tool/PhotoSheetGenerator.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | from PIL import Image, ImageDraw 3 | import numpy as np 4 | 5 | class PhotoSheetGenerator: 6 | def __init__(self, five_inch_size=(1050, 1500), dpi=300): 7 | self.five_inch_size = five_inch_size 8 | self.dpi = dpi 9 | 10 | @staticmethod 11 | def cv2_to_pillow(cv2_image): 12 | """Convert OpenCV image data to Pillow image""" 13 | cv2_image_rgb = cv2.cvtColor(cv2_image, cv2.COLOR_BGR2RGB) 14 | return Image.fromarray(cv2_image_rgb) 15 | 16 | @staticmethod 17 | def pillow_to_cv2(pillow_image): 18 | """Convert Pillow image to OpenCV image data""" 19 | cv2_image_rgb = cv2.cvtColor(np.array(pillow_image), cv2.COLOR_RGB2BGR) 20 | return cv2_image_rgb 21 | 22 | def generate_photo_sheet(self, one_inch_photo_cv2, rows=3, cols=3, rotate=False, add_crop_lines=True): 23 | one_inch_height, one_inch_width = one_inch_photo_cv2.shape[:2] 24 | 25 | # Convert OpenCV image data to Pillow image 26 | one_inch_photo_pillow = self.cv2_to_pillow(one_inch_photo_cv2) 27 | 28 | # Rotate photo 29 | if rotate: 30 | one_inch_photo_pillow = one_inch_photo_pillow.rotate(90, expand=True) 31 | one_inch_height, one_inch_width = one_inch_width, one_inch_height 32 | 33 | # Create photo sheet (white background) 34 | five_inch_photo = Image.new('RGB', self.five_inch_size, 'white') 35 | 36 | # Calculate positions for the photos on the sheet 37 | total_width = cols * one_inch_width 38 | total_height = rows * one_inch_height 39 | 40 | if total_width > self.five_inch_size[0] or total_height > self.five_inch_size[1]: 41 | raise ValueError("The specified layout exceeds the size of the photo sheet") 42 | 43 | start_x = (self.five_inch_size[0] - total_width) // 2 44 | start_y = (self.five_inch_size[1] - total_height) // 2 45 | 46 | # Arrange photos on the sheet in an n*m layout 47 | for i in range(rows): 48 | for j in range(cols): 49 | x = start_x + j * one_inch_width 50 | y = start_y + i * one_inch_height 51 | five_inch_photo.paste(one_inch_photo_pillow, (x, y)) 52 | 53 | # Draw crop lines if requested 54 | if add_crop_lines: 55 | draw = ImageDraw.Draw(five_inch_photo) 56 | 57 | # Draw outer rectangle 58 | draw.rectangle([start_x, start_y, start_x + total_width, start_y + total_height], outline="black") 59 | draw.rectangle([start_x, start_y, self.five_inch_size[0], self.five_inch_size[1]], outline="black") 60 | 61 | # Draw inner lines 62 | for i in range(1, rows): 63 | y = start_y + i * one_inch_height 64 | draw.line([(start_x, y), (start_x + total_width, y)], fill="black") 65 | 66 | for j in range(1, cols): 67 | x = start_x + j * one_inch_width 68 | draw.line([(x, start_y), (x, start_y + total_height)], fill="black") 69 | 70 | # Set the DPI information 71 | five_inch_photo.info['dpi'] = (self.dpi, self.dpi) 72 | 73 | # Return the generated photo sheet as a Pillow image 74 | return self.pillow_to_cv2(five_inch_photo) 75 | 76 | def save_photo_sheet(self, photo_sheet_cv, output_path): 77 | """Save the generated photo sheet as an image file""" 78 | if not isinstance(output_path, str): 79 | raise TypeError("output_path must be a string") 80 | if not output_path.lower().endswith(('.png', '.jpg', '.jpeg')): 81 | raise ValueError("output_path must be a valid image file path ending with .png, .jpg, or .jpeg") 82 | try: 83 | photo_sheet = self.cv2_to_pillow(photo_sheet_cv) 84 | photo_sheet.save(output_path, dpi=(self.dpi, self.dpi)) 85 | except Exception as e: 86 | raise IOError(f"Failed to save photo: {e}") -------------------------------------------------------------------------------- /src/tool/YuNet.py: -------------------------------------------------------------------------------- 1 | import cv2 as cv 2 | import numpy as np 3 | 4 | 5 | class YuNet: 6 | """ 7 | YuNet face detector class. 8 | 9 | :param model_path: Path to the model file 10 | :type model_path: str 11 | :param input_size: Size of the input image, in the form [w, h], default is [320, 320] 12 | :type input_size: list[int] 13 | :param conf_threshold: Confidence threshold, default is 0.6 14 | :type conf_threshold: float 15 | :param nms_threshold: Non-maximum suppression threshold, default is 0.3 16 | :type nms_threshold: float 17 | :param top_k: Number of top detections to keep, default is 5000 18 | :type top_k: int 19 | :param backend_id: ID of the backend to use, default is 0 20 | :type backend_id: int 21 | :param target_id: ID of the target device, default is 0 22 | :type target_id: int 23 | :return: None 24 | :rtype: None 25 | """ 26 | 27 | def __init__(self, model_path, input_size=[320, 320], conf_threshold=0.6, nms_threshold=0.3, top_k=5000, 28 | backend_id=0, 29 | target_id=0): 30 | self._model_path = model_path 31 | self._input_size = tuple(input_size) # [w, h] 32 | self._conf_threshold = conf_threshold 33 | self._nms_threshold = nms_threshold 34 | self._top_k = top_k 35 | self._backend_id = backend_id 36 | self._target_id = target_id 37 | 38 | self._model = cv.FaceDetectorYN.create( 39 | model=self._model_path, 40 | config="", 41 | input_size=self._input_size, 42 | score_threshold=self._conf_threshold, 43 | nms_threshold=self._nms_threshold, 44 | top_k=self._top_k, 45 | backend_id=self._backend_id, 46 | target_id=self._target_id) 47 | 48 | @property 49 | def name(self): 50 | return self.__class__.__name__ 51 | 52 | def set_backend_and_target(self, backend_id, target_id): 53 | """ 54 | Set the backend ID and target ID. 55 | 56 | :param backend_id: Backend ID 57 | :type backend_id: int 58 | :param target_id: Target ID 59 | :type target_id: int 60 | :return: None 61 | :rtype: None 62 | """ 63 | self._backend_id = backend_id 64 | self._target_id = target_id 65 | self._model = cv.FaceDetectorYN.create( 66 | model=self._model_path, 67 | config="", 68 | input_size=self._input_size, 69 | score_threshold=self._conf_threshold, 70 | nms_threshold=self._nms_threshold, 71 | top_k=self._top_k, 72 | backend_id=self._backend_id, 73 | target_id=self._target_id) 74 | 75 | def set_input_size(self, input_size): 76 | """ 77 | Set the size of the input image. 78 | 79 | :param input_size: Size of the input image, in the form [w, h] 80 | :type input_size: list[int] 81 | :return: None 82 | :rtype: None 83 | """ 84 | self._model.setInputSize(tuple(input_size)) 85 | 86 | def infer(self, image): 87 | """ 88 | Perform inference to detect faces in the image. 89 | 90 | :param image: The image to be processed 91 | :type image: numpy.ndarray 92 | :return: Detected face information, a numpy array of shape [n, 15], where each row represents a detected face with 15 elements: [x1, y1, x2, y2, score, x3, y3, x4, y4, x5, y5, x6, y6, x7, y7] 93 | :rtype: numpy.ndarray 94 | """ 95 | # Forward inference 96 | faces = self._model.detect(image) 97 | return faces[1] 98 | 99 | 100 | class FaceDetector: 101 | """ 102 | Face detector class. 103 | 104 | :param model_path: Path to the model file 105 | :type model_path: str 106 | :param conf_threshold: Minimum confidence threshold, default is 0.9 107 | :type conf_threshold: float 108 | :param nms_threshold: Non-maximum suppression threshold, default is 0.3 109 | :type nms_threshold: float 110 | :param top_k: Number of top detections to keep, default is 5000 111 | :type top_k: int 112 | :param backend_id: Backend ID, default is cv2.dnn.DNN_BACKEND_OPENCV 113 | :type backend_id: int 114 | :param target_id: Target ID, default is cv2.dnn.DNN_TARGET_CPU 115 | :type target_id: int 116 | :return: None 117 | :rtype: None 118 | """ 119 | 120 | def __init__(self, model_path, conf_threshold=0.9, nms_threshold=0.3, top_k=5000, 121 | backend_id=cv.dnn.DNN_BACKEND_OPENCV, target_id=cv.dnn.DNN_TARGET_CPU): 122 | self.model = YuNet(model_path=model_path, 123 | input_size=[320, 320], 124 | conf_threshold=conf_threshold, 125 | nms_threshold=nms_threshold, 126 | top_k=top_k, 127 | backend_id=backend_id, 128 | target_id=target_id) 129 | 130 | def process_image(self, image_path, origin_size=False): 131 | """ 132 | Process the image for face detection. 133 | 134 | :param image_path: Path to the image file to be processed 135 | :type image_path: str 136 | :param origin_size: Whether to keep the original size 137 | :type origin_size: bool 138 | :return: Detected face information, a numpy array of shape [n, 15], where each row represents a detected face with 15 elements: [x1, y1, x2, y2, score, x3, y3, x4, y4, x5, y5, x6, y6, x7, y7] 139 | :rtype: numpy.ndarray 140 | """ 141 | image = cv.imdecode(np.fromfile(image_path, dtype=np.uint8), cv.IMREAD_COLOR) 142 | h, w, _ = image.shape 143 | target_size = 320 144 | max_size = 320 145 | im_shape = image.shape 146 | im_size_min = np.min(im_shape[0:2]) 147 | im_size_max = np.max(im_shape[0:2]) 148 | resize_factor = float(target_size) / float(im_size_min) 149 | 150 | if np.round(resize_factor * im_size_max) > max_size: 151 | resize_factor = float(max_size) / float(im_size_max) 152 | 153 | if origin_size: 154 | resize_factor = 1 155 | 156 | if resize_factor != 1: 157 | image = cv.resize(image, None, None, fx=resize_factor, fy=resize_factor, interpolation=cv.INTER_LINEAR) 158 | h, w, _ = image.shape 159 | 160 | self.model.set_input_size([w, h]) 161 | results = self.model.infer(image) 162 | if results is not None: 163 | if resize_factor != 1: 164 | results = results[:, :15] / resize_factor 165 | else: 166 | results = [] 167 | 168 | return results 169 | -------------------------------------------------------------------------------- /src/tool/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aoguai/LiYing/006d52a10006daa1b885cbca064a2158400107c4/src/tool/__init__.py -------------------------------------------------------------------------------- /src/tool/ext/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /src/tool/yolov8_detector.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | import cv2 as cv 5 | import numpy as np 6 | import onnxruntime as ort 7 | 8 | 9 | class YOLOv8Detector: 10 | def __init__(self, model_path, input_size=(640, 640), box_score=0.25, kpt_score=0.5, nms_thr=0.2): 11 | """ 12 | Initialize the YOLOv8 detector 13 | 14 | Parameters: 15 | model_path (str): Path to the model file, can be an absolute or relative path. 16 | input_size (tuple): Input image size. 17 | box_score (float): Confidence threshold for detection boxes. 18 | kpt_score (float): Confidence threshold for keypoints. 19 | nms_thr (float): Non-Maximum Suppression (NMS) threshold. 20 | """ 21 | assert model_path.endswith('.onnx'), f"invalid onnx model: {model_path}" 22 | assert os.path.exists(model_path), f"model not found: {model_path}" 23 | 24 | # Set log level to ERROR to disable default console info output 25 | logging.getLogger('ultralytics').setLevel(logging.ERROR) 26 | 27 | # Create ONNX Runtime session 28 | self.session = ort.InferenceSession(model_path) 29 | self.input_name = self.session.get_inputs()[0].name 30 | self.input_size = input_size 31 | self.box_score = box_score 32 | self.kpt_score = kpt_score 33 | self.nms_thr = nms_thr 34 | 35 | def preprocess(self, img_path): 36 | # Read the image 37 | img = cv.imdecode(np.fromfile(img_path, dtype=np.uint8), -1) 38 | if img is None: 39 | raise ValueError(f"Failed to read image from {img_path}") 40 | 41 | input_w, input_h = self.input_size 42 | padded_img = np.ones((input_h, input_w, 3), dtype=np.uint8) * 114 43 | r = min(input_w / img.shape[1], input_h / img.shape[0]) 44 | resized_img = cv.resize(img, (int(img.shape[1] * r), int(img.shape[0] * r)), 45 | interpolation=cv.INTER_LINEAR).astype(np.uint8) 46 | padded_img[: int(img.shape[0] * r), : int(img.shape[1] * r)] = resized_img 47 | padded_img = padded_img.transpose((2, 0, 1))[::-1, ] 48 | padded_img = np.ascontiguousarray(padded_img, dtype=np.float32) / 255.0 49 | return padded_img, r, img 50 | 51 | def postprocess(self, output, ratio): 52 | predict = output[0].squeeze(0).T 53 | predict = predict[predict[:, 4] > self.box_score, :] 54 | scores = predict[:, 4] 55 | boxes = predict[:, 0:4] / ratio 56 | boxes = self.xywh2xyxy(boxes) 57 | kpts = predict[:, 5:] 58 | for i in range(kpts.shape[0]): 59 | for j in range(kpts.shape[1] // 3): 60 | if kpts[i, 3 * j + 2] < self.kpt_score: 61 | kpts[i, 3 * j: 3 * (j + 1)] = [-1, -1, -1] 62 | else: 63 | kpts[i, 3 * j] /= ratio 64 | kpts[i, 3 * j + 1] /= ratio 65 | idxes = self.nms_process(boxes, scores) 66 | result = {'boxes': boxes[idxes, :].astype(int).tolist(), 'kpts': kpts[idxes, :].astype(float).tolist(), 67 | 'scores': scores[idxes].tolist()} 68 | return result 69 | 70 | def xywh2xyxy(self, box): 71 | box_xyxy = box.copy() 72 | box_xyxy[..., 0] = box[..., 0] - box[..., 2] / 2 73 | box_xyxy[..., 1] = box[..., 1] - box[..., 3] / 2 74 | box_xyxy[..., 2] = box[..., 0] + box[..., 2] / 2 75 | box_xyxy[..., 3] = box[..., 1] + box[..., 3] / 2 76 | return box_xyxy 77 | 78 | def nms_process(self, boxes, scores): 79 | sorted_idx = np.argsort(scores)[::-1] 80 | keep_idx = [] 81 | while sorted_idx.size > 0: 82 | idx = sorted_idx[0] 83 | keep_idx.append(idx) 84 | ious = self.compute_iou(boxes[idx, :], boxes[sorted_idx[1:], :]) 85 | rest_idx = np.where(ious < self.nms_thr)[0] 86 | sorted_idx = sorted_idx[rest_idx + 1] 87 | return keep_idx 88 | 89 | def compute_iou(self, box, boxes): 90 | xmin = np.maximum(box[0], boxes[:, 0]) 91 | ymin = np.maximum(box[1], boxes[:, 1]) 92 | xmax = np.minimum(box[2], boxes[:, 2]) 93 | ymax = np.minimum(box[3], boxes[:, 3]) 94 | inter_area = np.maximum(0, xmax - xmin) * np.maximum(0, ymax - ymin) 95 | box_area = (box[2] - box[0]) * (box[3] - box[1]) 96 | boxes_area = (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1]) 97 | union_area = box_area + boxes_area - inter_area 98 | return inter_area / union_area 99 | 100 | def detect(self, img_path): 101 | """ 102 | Detect objects in an image 103 | 104 | Parameters: 105 | img_path (str): Path to the image file, can be an absolute or relative path. 106 | 107 | Returns: 108 | results: Detection results. 109 | """ 110 | image, ratio, original_img = self.preprocess(img_path) 111 | ort_input = {self.input_name: image[None, :]} 112 | output = self.session.run(None, ort_input) 113 | result = self.postprocess(output, ratio) 114 | return result, original_img 115 | 116 | def detect_person(self, img_path): 117 | """ 118 | Detect if there is only one person in the image 119 | 120 | Parameters: 121 | img_path (str): Path to the image file, can be an absolute or relative path. 122 | 123 | Returns: 124 | dict: Contains the coordinates of the box, predicted class, coordinates of all keypoints, and confidence scores. 125 | If more or fewer than one person is detected, returns None. 126 | """ 127 | result, original_img = self.detect(img_path) 128 | boxes = result['boxes'] 129 | scores = result['scores'] 130 | kpts = result['kpts'] 131 | 132 | # Only handle cases where exactly one person is detected 133 | if len(boxes) == 1: 134 | bbox_xyxy = boxes[0] 135 | bbox_label = 0 # Assuming person class is 0 136 | bbox_keypoints = kpts[0] 137 | return { 138 | 'bbox_xyxy': bbox_xyxy, 139 | 'bbox_label': bbox_label, 140 | 'bbox_keypoints': bbox_keypoints 141 | }, original_img 142 | return None, original_img 143 | 144 | def draw_result(self, img, result, with_label=False): 145 | boxes, kpts, scores = result['boxes'], result['kpts'], result['scores'] 146 | for box, kpt, score in zip(boxes, kpts, scores): 147 | x1, y1, x2, y2 = box 148 | label_str = "{:.0f}%".format(score * 100) 149 | label_size, baseline = cv.getTextSize(label_str, cv.FONT_HERSHEY_SIMPLEX, 0.5, 2) 150 | cv.rectangle(img, (x1, y1), (x2, y2), (0, 0, 255), 2) 151 | if with_label: 152 | cv.rectangle(img, (x1, y1), (x1 + label_size[0], y1 + label_size[1] + baseline), (0, 0, 255), -1) 153 | cv.putText(img, label_str, (x1, y1 + label_size[1]), cv.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 2) 154 | for idx in range(len(kpt) // 3): 155 | x, y, score = kpt[3 * idx: 3 * (idx + 1)] 156 | if score > 0: 157 | cv.circle(img, (int(x), int(y)), 2, (0, 255, 0), -1) 158 | return img 159 | -------------------------------------------------------------------------------- /src/webui/app.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | import locale 4 | import os 5 | import re 6 | import sys 7 | import time 8 | 9 | import cv2 10 | import gradio as gr 11 | import pandas as pd 12 | from PIL import Image 13 | 14 | current_dir = os.path.dirname(os.path.abspath(__file__)) 15 | src_dir = os.path.dirname(current_dir) 16 | project_root = os.path.dirname(src_dir) 17 | sys.path.insert(0, src_dir) 18 | 19 | PROJECT_ROOT = project_root 20 | DATA_DIR = os.path.join(PROJECT_ROOT, 'data') 21 | TOOL_DIR = os.path.join(src_dir, 'tool') 22 | MODEL_DIR = os.path.join(src_dir, 'model') 23 | SAVE_IMG_DIR = os.path.join(PROJECT_ROOT, 'output') 24 | 25 | DEFAULT_YOLOV8_PATH = os.path.join(MODEL_DIR, 'yolov8n-pose.onnx') 26 | DEFAULT_YUNET_PATH = os.path.join(MODEL_DIR, 'face_detection_yunet_2023mar.onnx') 27 | DEFAULT_RMBG_PATH = os.path.join(MODEL_DIR, 'RMBG-1.4-model.onnx') 28 | DEFAULT_SIZE_CONFIG = os.path.join(DATA_DIR, 'size_{}.csv') 29 | DEFAULT_COLOR_CONFIG = os.path.join(DATA_DIR, 'color_{}.csv') 30 | 31 | sys.path.extend([DATA_DIR, MODEL_DIR, TOOL_DIR]) 32 | 33 | from tool.ImageProcessor import ImageProcessor 34 | from tool.PhotoSheetGenerator import PhotoSheetGenerator 35 | from tool.PhotoRequirements import PhotoRequirements 36 | from tool.ConfigManager import ConfigManager 37 | 38 | def get_language(): 39 | """Get the system language or default to English.""" 40 | try: 41 | system_lang = locale.getlocale()[0].split('_')[0] 42 | return system_lang if system_lang in ['en', 'zh'] else 'en' 43 | except: 44 | return 'en' 45 | 46 | def load_i18n_texts(): 47 | """Load internationalization texts from JSON files.""" 48 | i18n_dir = os.path.join(os.path.dirname(__file__), 'i18n') 49 | texts = {} 50 | for lang in ['en', 'zh']: 51 | with open(os.path.join(i18n_dir, f'{lang}.json'), 'r', encoding='utf-8') as f: 52 | texts[lang] = json.load(f) 53 | return texts 54 | 55 | TEXTS = load_i18n_texts() 56 | 57 | def t(key, language): 58 | """Translate a key to the specified language.""" 59 | return TEXTS.get(language, {}).get(key, TEXTS.get('en', {}).get(key, key)) 60 | 61 | def parse_color(color_string): 62 | """Parse color string to RGB list.""" 63 | if color_string is None: 64 | return [255, 255, 255] 65 | if color_string.startswith('#'): 66 | return [int(color_string.lstrip('#')[i:i+2], 16) for i in (0, 2, 4)] 67 | rgb_match = re.match(r'rgba?$(\d+\.?\d*),\s*(\d+\.?\d*),\s*(\d+\.?\d*)(?:,\s*[\d.]+)?$', color_string) 68 | if rgb_match: 69 | return [min(255, max(0, int(float(x)))) for x in rgb_match.groups()] 70 | return [255, 255, 255] 71 | 72 | def process_image(img_path, yolov8_path, yunet_path, rmbg_path, photo_requirements, photo_type, photo_sheet_size, rgb_list, compress=False, change_background=False, rotate=False, resize=True, sheet_rows=3, sheet_cols=3, add_crop_lines=True, target_size=None, size_range=None, use_csv_size=True): 73 | """Process the image with specified parameters.""" 74 | processor = ImageProcessor(img_path, 75 | yolov8_model_path=yolov8_path, 76 | yunet_model_path=yunet_path, 77 | RMBG_model_path=rmbg_path, 78 | rgb_list=rgb_list, 79 | y_b=compress) 80 | 81 | processor.crop_and_correct_image() 82 | 83 | # Get file size limits from CSV if enabled 84 | file_size_limits = {} 85 | if use_csv_size: 86 | file_size_limits = photo_requirements.get_file_size_limits(photo_type) 87 | 88 | # User-provided limits override CSV limits 89 | if target_size is not None: 90 | file_size_limits = {'target_size': target_size} 91 | elif size_range is not None and len(size_range) == 2: 92 | # Convert from list to tuple for size_range parameter 93 | file_size_limits = {'size_range': tuple(size_range)} 94 | 95 | if change_background: 96 | processor.change_background() 97 | 98 | if resize: 99 | processor.resize_image(photo_type) 100 | 101 | sheet_info = photo_requirements.get_resize_image_list(photo_sheet_size) 102 | sheet_width, sheet_height, sheet_resolution = sheet_info['width'], sheet_info['height'], sheet_info['resolution'] 103 | generator = PhotoSheetGenerator((sheet_width, sheet_height), sheet_resolution) 104 | photo_sheet_cv = generator.generate_photo_sheet(processor.photo.image, sheet_rows, sheet_cols, rotate, add_crop_lines) 105 | 106 | return { 107 | 'final_image': photo_sheet_cv, 108 | 'corrected_image': processor.photo.image, 109 | 'file_size_limits': file_size_limits 110 | } 111 | 112 | def save_image(image, filename, file_format='png', resolution=300): 113 | """ 114 | Save the image, supporting different formats and resolutions 115 | 116 | :param image: numpy image array 117 | :param filename: name of the file to save 118 | :param file_format: file format, such as png, jpg, tif, etc., default is png 119 | :param resolution: image resolution (DPI), default is 300 120 | """ 121 | 122 | if not filename.lower().endswith(file_format): 123 | filename = f"{os.path.splitext(filename)[0]}.{file_format}" 124 | 125 | os.makedirs(os.path.dirname(filename), exist_ok=True) 126 | 127 | pil_image = Image.fromarray(image) 128 | pil_image.info['dpi'] = (resolution, resolution) 129 | pil_image.save(filename, dpi=(resolution, resolution)) 130 | 131 | 132 | def create_demo(initial_language): 133 | """Create the Gradio demo interface.""" 134 | config_manager = ConfigManager(language=initial_language) 135 | config_manager.load_configs() 136 | photo_requirements = PhotoRequirements(language=initial_language) 137 | 138 | def update_configs(): 139 | nonlocal photo_size_configs, sheet_size_configs, color_configs, photo_size_choices, sheet_size_choices, color_choices 140 | photo_size_configs = config_manager.get_photo_size_configs() 141 | sheet_size_configs = config_manager.get_sheet_size_configs() 142 | color_configs = config_manager.color_config 143 | photo_size_choices = list(photo_size_configs.keys()) 144 | sheet_size_choices = list(sheet_size_configs.keys()) 145 | color_choices = [t('custom_color', config_manager.language)] + list(color_configs.keys()) 146 | 147 | update_configs() 148 | 149 | photo_size_configs = config_manager.get_photo_size_configs() 150 | sheet_size_configs = config_manager.get_sheet_size_configs() 151 | color_configs = config_manager.color_config 152 | 153 | photo_size_choices = list(photo_size_configs.keys()) 154 | sheet_size_choices = list(sheet_size_configs.keys()) 155 | color_choices = [t('custom_color', initial_language)] + list(color_configs.keys()) 156 | 157 | with gr.Blocks(theme=gr.themes.Soft()) as demo: 158 | language = gr.State(initial_language) 159 | 160 | title = gr.Markdown(f"# {t('title', initial_language)}") 161 | 162 | color_change_source = {"source": "custom"} 163 | 164 | current_file_format = 'png' 165 | current_resolution = 300 166 | 167 | with gr.Row(): 168 | with gr.Column(scale=1): 169 | input_image = gr.Image(type="numpy", label=t('upload_photo', initial_language), height=400) 170 | lang_dropdown = gr.Dropdown( 171 | choices=[("English", "en"), ("中文", "zh")], 172 | value=initial_language, 173 | label=t('language', initial_language) 174 | ) 175 | 176 | with gr.Tabs() as tabs: 177 | with gr.TabItem(t('key_param', initial_language)) as key_param_tab: 178 | photo_type = gr.Dropdown( 179 | choices=photo_size_choices, 180 | label=t('photo_type', initial_language), 181 | value=photo_size_choices[0] if photo_size_choices else None 182 | ) 183 | photo_sheet_size = gr.Dropdown( 184 | choices=sheet_size_choices, 185 | label=t('photo_sheet_size', initial_language), 186 | value=sheet_size_choices[0] if sheet_size_choices else None 187 | ) 188 | with gr.Row(): 189 | preset_color = gr.Dropdown(choices=color_choices, label=t('preset_color', initial_language), value=t('custom_color', initial_language)) 190 | background_color = gr.ColorPicker(label=t('background_color', initial_language), value="#FFFFFF") 191 | layout_only = gr.Checkbox(label=t('layout_only', initial_language), value=False) 192 | sheet_rows = gr.Slider(minimum=1, maximum=10, step=1, value=3, label=t('sheet_rows', initial_language)) 193 | sheet_cols = gr.Slider(minimum=1, maximum=10, step=1, value=3, label=t('sheet_cols', initial_language)) 194 | 195 | with gr.TabItem(t('advanced_settings', initial_language)) as advanced_settings_tab: 196 | yolov8_path = gr.Textbox(label=t('yolov8_path', initial_language), value=DEFAULT_YOLOV8_PATH) 197 | yunet_path = gr.Textbox(label=t('yunet_path', initial_language), value=DEFAULT_YUNET_PATH) 198 | rmbg_path = gr.Textbox(label=t('rmbg_path', initial_language), value=DEFAULT_RMBG_PATH) 199 | size_config = gr.Textbox(label=t('size_config', initial_language), value=DEFAULT_SIZE_CONFIG.format(initial_language)) 200 | color_config = gr.Textbox(label=t('color_config', initial_language), value=DEFAULT_COLOR_CONFIG.format(initial_language)) 201 | compress = gr.Checkbox(label=t('compress', initial_language), value=True) 202 | change_background = gr.Checkbox(label=t('change_background', initial_language), value=True) 203 | rotate = gr.Checkbox(label=t('rotate', initial_language), value=False) 204 | resize = gr.Checkbox(label=t('resize', initial_language), value=True) 205 | add_crop_lines = gr.Checkbox(label=t('add_crop_lines', initial_language), value=True) 206 | 207 | # Add file size limit control items 208 | with gr.Row(): 209 | use_csv_size = gr.Checkbox(label=t('use_csv_size', initial_language) if 'use_csv_size' in TEXTS[initial_language] else 'Use size limits from CSV', value=True) 210 | 211 | # New Radio buttons for selecting size input type 212 | size_option_choices = [ 213 | (t('target_size_radio', initial_language), "target"), 214 | (t('size_range_radio', initial_language), "range") 215 | ] 216 | size_option_type = gr.Radio( 217 | choices=size_option_choices, 218 | label=t('size_input_option', initial_language), 219 | value="target", # Default selection when it becomes visible 220 | visible=False # Initially hidden 221 | ) 222 | 223 | with gr.Row(): 224 | target_size = gr.Number(label=t('target_size', initial_language) if 'target_size' in TEXTS[initial_language] else 'Target file size (KB)', precision=0, visible=False) 225 | 226 | with gr.Row(): 227 | size_range_min = gr.Number(label=t('size_range_min', initial_language) if 'size_range_min' in TEXTS[initial_language] else 'Min file size (KB)', precision=0, visible=False) 228 | size_range_max = gr.Number(label=t('size_range_max', initial_language) if 'size_range_max' in TEXTS[initial_language] else 'Max file size (KB)', precision=0, visible=False) 229 | 230 | confirm_advanced_settings = gr.Button(t('confirm_settings', initial_language)) 231 | 232 | with gr.TabItem(t('config_management', initial_language)) as config_management_tab: 233 | with gr.Tabs() as config_tabs: 234 | with gr.TabItem(t('size_config', initial_language)) as size_config_tab: 235 | size_df = gr.Dataframe( 236 | value=pd.DataFrame( 237 | [ 238 | [name] + list(config.values()) 239 | for name, config in config_manager.size_config.items() 240 | ], 241 | columns=['Name'] + (list(next(iter(config_manager.size_config.values())).keys()) if config_manager.size_config else []) 242 | ), 243 | interactive=True, 244 | label=t('size_config_table', initial_language) 245 | ) 246 | with gr.Row(): 247 | add_size_btn = gr.Button(t('add_size', initial_language)) 248 | update_size_btn = gr.Button(t('save_size', initial_language)) 249 | 250 | with gr.TabItem(t('color_config', initial_language)) as color_config_tab: 251 | color_df = gr.Dataframe( 252 | value=pd.DataFrame( 253 | [ 254 | [name] + list(config.values()) 255 | for name, config in config_manager.color_config.items() 256 | ], 257 | columns=['Name', 'R', 'G', 'B', 'Notes'] 258 | ), 259 | interactive=True, 260 | label=t('color_config_table', initial_language) 261 | ) 262 | with gr.Row(): 263 | add_color_btn = gr.Button(t('add_color', initial_language)) 264 | update_color_btn = gr.Button(t('save_color', initial_language)) 265 | 266 | config_notification = gr.Textbox(label=t('config_notification', initial_language)) 267 | 268 | process_btn = gr.Button(t('process_btn', initial_language)) 269 | 270 | with gr.Column(scale=1): 271 | with gr.Tabs() as result_tabs: 272 | with gr.TabItem(t('result', initial_language)) as result_tab: 273 | output_image = gr.Image(label=t('final_image', initial_language), height=800) 274 | with gr.Row(): 275 | save_final_btn = gr.Button(t('save_image', initial_language)) 276 | save_final_path = gr.Textbox(label=t('save_path', initial_language), value=SAVE_IMG_DIR) 277 | with gr.TabItem(t('corrected_image', initial_language)) as corrected_image_tab: 278 | corrected_output = gr.Image(label=t('corrected_image', initial_language), height=800) 279 | with gr.Row(): 280 | save_corrected_btn = gr.Button(t('save_corrected', initial_language)) 281 | save_corrected_path = gr.Textbox(label=t('save_path', initial_language), value=SAVE_IMG_DIR) 282 | notification = gr.Textbox(label=t('notification', initial_language)) 283 | 284 | def process_and_display_wrapper(input_image, yolov8_path, yunet_path, rmbg_path, size_config, color_config, 285 | photo_type, photo_sheet_size, background_color, compress, change_background, 286 | rotate, resize, sheet_rows, sheet_cols, layout_only, add_crop_lines, 287 | target_size, size_range_min, size_range_max, use_csv_size): 288 | nonlocal current_file_format, current_resolution 289 | 290 | validated_target_size = None 291 | validated_size_range = None 292 | 293 | if target_size is not None and target_size > 0: 294 | validated_target_size = int(target_size) 295 | 296 | if size_range_min is not None and size_range_max is not None and size_range_min > 0 and size_range_max > 0: 297 | if size_range_min < size_range_max: 298 | validated_size_range = [int(size_range_min), int(size_range_max)] 299 | else: 300 | gr.Warning(t('size_range_error', language) if 'size_range_error' in TEXTS[language] else "Min size must be less than max size") 301 | 302 | if validated_target_size is not None and validated_size_range is not None: 303 | gr.Warning(t('size_params_conflict', language) if 'size_params_conflict' in TEXTS[language] else "Both target size and size range provided. Using target size.") 304 | validated_size_range = None 305 | 306 | result = process_and_display( 307 | input_image, yolov8_path, yunet_path, rmbg_path, size_config, color_config, 308 | photo_type, photo_sheet_size, background_color, compress, change_background, 309 | rotate, resize, sheet_rows, sheet_cols, layout_only, add_crop_lines, 310 | validated_target_size, validated_size_range, use_csv_size 311 | ) 312 | 313 | if result: 314 | final_image, corrected_image, file_format, resolution = result 315 | current_file_format = file_format if file_format else 'png' 316 | current_resolution = resolution if resolution else 300 317 | return final_image, corrected_image 318 | return None, None 319 | 320 | def save_image_handler(image, path, lang, photo_type, photo_sheet_size, background_color): 321 | nonlocal current_file_format, current_resolution 322 | if image is None: 323 | return t('no_image_to_save', lang) 324 | 325 | if not path.strip(): 326 | path = SAVE_IMG_DIR 327 | 328 | path = os.path.normpath(path) 329 | 330 | if os.path.exists(path): 331 | is_dir = os.path.isdir(path) 332 | else: 333 | file_ext = os.path.splitext(path)[1] 334 | is_dir = file_ext == '' 335 | 336 | if is_dir: 337 | os.makedirs(path, exist_ok=True) 338 | filename = f"{photo_sheet_size}_{photo_type}_{str(background_color)}_{int(time.time())}.{current_file_format}" 339 | full_path = os.path.join(path, filename) 340 | else: 341 | dir_name = os.path.dirname(path) 342 | base_name, ext = os.path.splitext(os.path.basename(path)) 343 | base_name = re.sub(r'[<>:"/\\|?*]', '_', base_name) # 仅过滤文件名非法字符 344 | filename = f"{base_name}.{current_file_format}" 345 | 346 | if dir_name: 347 | os.makedirs(dir_name, exist_ok=True) 348 | 349 | full_path = os.path.join(dir_name, filename) if dir_name else filename 350 | 351 | try: 352 | save_image(image, full_path, current_file_format, current_resolution) 353 | return t('image_saved_success', lang).format(path=full_path) 354 | except Exception as e: 355 | return t('image_save_error', lang).format(error=str(e)) 356 | 357 | def update_language(lang): 358 | """Update UI language and reload configs.""" 359 | nonlocal config_manager, photo_requirements 360 | config_manager.switch_language(lang) 361 | photo_requirements.switch_language(lang) 362 | update_configs() 363 | 364 | new_photo_size_configs = config_manager.get_photo_size_configs() 365 | new_sheet_size_configs = config_manager.get_sheet_size_configs() 366 | color_configs = config_manager.color_config 367 | 368 | new_photo_size_choices = list(new_photo_size_configs.keys()) 369 | new_sheet_size_choices = list(new_sheet_size_configs.keys()) 370 | new_color_choices = [t('custom_color', lang)] + list(color_configs.keys()) 371 | 372 | # The dictionary of updates to be returned 373 | updates = { 374 | title: gr.update(value=f"# {t('title', lang)}"), 375 | input_image: gr.update(label=t('upload_photo', lang)), 376 | lang_dropdown: gr.update(label=t('language', lang)), 377 | photo_type: gr.update(choices=new_photo_size_choices, label=t('photo_type', lang), value=new_photo_size_choices[0] if new_photo_size_choices else None), 378 | photo_sheet_size: gr.update(choices=new_sheet_size_choices, label=t('photo_sheet_size', lang), value=new_sheet_size_choices[0] if new_sheet_size_choices else None), 379 | preset_color: gr.update(choices=new_color_choices, label=t('preset_color', lang)), 380 | background_color: gr.update(label=t('background_color', lang)), 381 | layout_only: gr.update(label=t('layout_only', lang)), 382 | sheet_rows: gr.update(label=t('sheet_rows', lang)), 383 | sheet_cols: gr.update(label=t('sheet_cols', lang)), 384 | yolov8_path: gr.update(label=t('yolov8_path', lang)), 385 | yunet_path: gr.update(label=t('yunet_path', lang)), 386 | rmbg_path: gr.update(label=t('rmbg_path', lang)), 387 | size_config: gr.update(label=t('size_config', lang), value=DEFAULT_SIZE_CONFIG.format(lang)), 388 | color_config: gr.update(label=t('color_config', lang), value=DEFAULT_COLOR_CONFIG.format(lang)), 389 | compress: gr.update(label=t('compress', lang)), 390 | change_background: gr.update(label=t('change_background', lang)), 391 | rotate: gr.update(label=t('rotate', lang)), 392 | resize: gr.update(label=t('resize', lang)), 393 | add_crop_lines: gr.update(label=t('add_crop_lines', lang)), 394 | use_csv_size: gr.update(label=t('use_csv_size', lang)), 395 | target_size: gr.update(label=t('target_size', lang)), 396 | size_range_min: gr.update(label=t('size_range_min', lang)), 397 | size_range_max: gr.update(label=t('size_range_max', lang)), 398 | process_btn: gr.update(value=t('process_btn', lang)), 399 | output_image: gr.update(label=t('final_image', lang)), 400 | corrected_output: gr.update(label=t('corrected_image', lang)), 401 | save_final_btn: gr.update(value=t('save_image', lang)), 402 | save_final_path: gr.update(label=t('save_path', lang)), 403 | save_corrected_btn: gr.update(value=t('save_corrected', lang)), 404 | save_corrected_path: gr.update(label=t('save_path', lang)), 405 | notification: gr.update(label=t('notification', lang)), 406 | key_param_tab: gr.update(label=t('key_param', lang)), 407 | advanced_settings_tab: gr.update(label=t('advanced_settings', lang)), 408 | config_management_tab: gr.update(label=t('config_management', lang)), 409 | size_config_tab: gr.update(label=t('size_config', lang)), 410 | color_config_tab: gr.update(label=t('color_config', lang)), 411 | confirm_advanced_settings: gr.update(value=t('confirm_settings', lang)), 412 | result_tab: gr.update(label=t('result', lang)), 413 | corrected_image_tab: gr.update(label=t('corrected_image', lang)), 414 | size_df: gr.update( 415 | value=pd.DataFrame( 416 | [[name] + list(config.values()) for name, config in config_manager.size_config.items()], 417 | columns=['Name'] + (list(next(iter(config_manager.size_config.values())).keys()) if config_manager.size_config else []) 418 | ), 419 | label=t('size_config_table', lang) 420 | ), 421 | color_df: gr.update( 422 | value=pd.DataFrame( 423 | [[name] + list(config.values()) for name, config in config_manager.color_config.items()], 424 | columns=['Name', 'R', 'G', 'B', 'Notes'] 425 | ), 426 | label=t('color_config_table', lang) 427 | ), 428 | add_size_btn: gr.update(value=t('add_size', lang)), 429 | update_size_btn: gr.update(value=t('save_size', lang)), 430 | add_color_btn: gr.update(value=t('add_color', lang)), 431 | update_color_btn: gr.update(value=t('save_color', lang)), 432 | config_notification: gr.update(label=t('config_notification', lang)), 433 | size_option_type: gr.update( 434 | label=t('size_input_option', lang), 435 | choices=[(t('target_size_radio', lang), "target"), (t('size_range_radio', lang), "range")] 436 | ), 437 | } 438 | # Add the language state update 439 | updates[language] = lang 440 | return updates 441 | 442 | def confirm_advanced_settings_fn(yolov8_path, yunet_path, rmbg_path, size_config, color_config): 443 | config_manager.size_file = size_config 444 | config_manager.color_file = color_config 445 | config_manager.load_configs() 446 | update_configs() 447 | return { 448 | size_df: gr.update(value=pd.DataFrame( 449 | [[name] + list(config.values()) for name, config in config_manager.size_config.items()], 450 | columns=['Name'] + list(next(iter(config_manager.size_config.values())).keys()) 451 | )), 452 | color_df: gr.update(value=pd.DataFrame( 453 | [[name] + list(config.values()) for name, config in config_manager.color_config.items()], 454 | columns=['Name', 'R', 'G', 'B', 'Notes'] 455 | )), 456 | photo_type: gr.update(choices=photo_size_choices), 457 | photo_sheet_size: gr.update(choices=sheet_size_choices), 458 | preset_color: gr.update(choices=color_choices), 459 | } 460 | 461 | def update_background_color(preset, lang): 462 | """Update background color based on preset selection.""" 463 | custom_color = t('custom_color', lang) 464 | if preset == custom_color: 465 | color_change_source["source"] = "custom" 466 | return gr.update() 467 | 468 | if preset in color_configs: 469 | color = color_configs[preset] 470 | hex_color = f"#{color['R']:02x}{color['G']:02x}{color['B']:02x}" 471 | color_change_source["source"] = "preset" 472 | return gr.update(value=hex_color) 473 | 474 | color_change_source["source"] = "custom" 475 | return gr.update(value="#FFFFFF") 476 | 477 | def update_preset_color(color, lang): 478 | """Update preset color dropdown based on color picker changes.""" 479 | if color_change_source["source"] == "preset": 480 | color_change_source["source"] = "custom" 481 | return gr.update() 482 | custom_color = t('custom_color', lang) 483 | return gr.update(value=custom_color) 484 | 485 | def process_and_display(image, yolov8_path, yunet_path, rmbg_path, size_config, color_config, photo_type, 486 | photo_sheet_size, background_color, compress, change_background, rotate, resize, 487 | sheet_rows, sheet_cols, layout_only, add_crop_lines, target_size=None, 488 | size_range=None, use_csv_size=True): 489 | """Process and display the image with given parameters.""" 490 | rgb_list = parse_color(background_color) 491 | image_bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR) 492 | temp_image_path = "temp_input_image.jpg" 493 | cv2.imwrite(temp_image_path, image_bgr) 494 | 495 | result = process_image( 496 | temp_image_path, 497 | yolov8_path, 498 | yunet_path, 499 | rmbg_path, 500 | photo_requirements, 501 | photo_type=photo_type, 502 | photo_sheet_size=photo_sheet_size, 503 | rgb_list=rgb_list, 504 | compress=compress, 505 | change_background=change_background and not layout_only, 506 | rotate=rotate, 507 | resize=resize, 508 | sheet_rows=sheet_rows, 509 | sheet_cols=sheet_cols, 510 | add_crop_lines=add_crop_lines, 511 | target_size=target_size, 512 | size_range=size_range, 513 | use_csv_size=use_csv_size 514 | ) 515 | 516 | os.remove(temp_image_path) 517 | 518 | sheet_info = photo_requirements.get_resize_image_list(photo_sheet_size) 519 | file_format = sheet_info.get('file_format', 'png').lower() 520 | if file_format == 'jpg': 521 | file_format = 'jpeg' 522 | resolution = sheet_info.get('resolution', 300) 523 | 524 | final_image_rgb = cv2.cvtColor(result['final_image'], cv2.COLOR_BGR2RGB) 525 | corrected_image_rgb = cv2.cvtColor(result['corrected_image'], cv2.COLOR_BGR2RGB) 526 | 527 | return final_image_rgb, corrected_image_rgb, file_format, resolution 528 | 529 | 530 | def add_size_config(df): 531 | """Add a new empty row to the size configuration table.""" 532 | new_row = pd.DataFrame([['' for _ in df.columns]], columns=df.columns) 533 | updated_df = pd.concat([df, new_row], ignore_index=True) 534 | return updated_df, t('size_config_row_added', config_manager.language) 535 | 536 | def update_size_config(df): 537 | """Save all changes made to the size configuration table and remove empty rows.""" 538 | updated_config = {} 539 | for _, row in df.iterrows(): 540 | name = row['Name'] 541 | if name and not row.iloc[1:].isna().all(): # Check if name exists and not all other fields are empty 542 | config = row.to_dict() 543 | config.pop('Name') 544 | updated_config[name] = config 545 | 546 | # Update the config_manager with the new configuration 547 | config_manager.size_config = updated_config 548 | config_manager.save_size_config() 549 | 550 | # Create a new dataframe with the updated configuration 551 | new_df = pd.DataFrame( 552 | [[name] + list(config.values()) for name, config in updated_config.items()], 553 | columns=['Name'] + (list(next(iter(updated_config.values())).keys()) if updated_config else []) 554 | ) 555 | 556 | return new_df, t('size_config_updated', config_manager.language) 557 | 558 | def add_color_config(df): 559 | """Add a new empty row to the color configuration table.""" 560 | new_row = pd.DataFrame([['' for _ in df.columns]], columns=df.columns) 561 | updated_df = pd.concat([df, new_row], ignore_index=True) 562 | return updated_df, t('color_config_row_added', config_manager.language) 563 | 564 | def update_color_config(df): 565 | """Save all changes made to the color configuration table and remove empty rows.""" 566 | updated_config = {} 567 | for _, row in df.iterrows(): 568 | name = row['Name'] 569 | if name and not row.iloc[1:].isna().all(): # Check if name exists and not all other fields are empty 570 | config = row.to_dict() 571 | config.pop('Name') 572 | updated_config[name] = config 573 | 574 | # Update the config_manager with the new configuration 575 | config_manager.color_config = updated_config 576 | config_manager.save_color_config() 577 | 578 | # Create a new dataframe with the updated configuration 579 | new_df = pd.DataFrame( 580 | [[name] + list(config.values()) for name, config in updated_config.items()], 581 | columns=['Name', 'R', 'G', 'B', 'Notes'] 582 | ) 583 | 584 | return new_df, t('color_config_updated', config_manager.language) 585 | 586 | def update_size_input_visibility(use_csv_val, option_type_val, lang_val): 587 | _size_option_choices_translated = [ 588 | (t('target_size_radio', lang_val), "target"), 589 | (t('size_range_radio', lang_val), "range") 590 | ] 591 | current_target_size_label = t('target_size', lang_val) 592 | current_size_range_min_label = t('size_range_min', lang_val) 593 | current_size_range_max_label = t('size_range_max', lang_val) 594 | current_size_option_label = t('size_input_option', lang_val) 595 | 596 | if use_csv_val: 597 | # If CSV size is used, hide all custom size options 598 | return { 599 | size_option_type: gr.update(visible=False, choices=_size_option_choices_translated, label=current_size_option_label), 600 | target_size: gr.update(visible=False, label=current_target_size_label), 601 | size_range_min: gr.update(visible=False, label=current_size_range_min_label), 602 | size_range_max: gr.update(visible=False, label=current_size_range_max_label) 603 | } 604 | else: 605 | # If CSV size is NOT used, show radio and relevant inputs 606 | is_target_selected = option_type_val == "target" 607 | return { 608 | size_option_type: gr.update(visible=True, choices=_size_option_choices_translated, label=current_size_option_label), 609 | target_size: gr.update(visible=is_target_selected, label=current_target_size_label), 610 | size_range_min: gr.update(visible=not is_target_selected, label=current_size_range_min_label), 611 | size_range_max: gr.update(visible=not is_target_selected, label=current_size_range_max_label) 612 | } 613 | 614 | lang_dropdown.change( 615 | update_language, 616 | inputs=[lang_dropdown], 617 | outputs=[title, input_image, lang_dropdown, photo_type, photo_sheet_size, preset_color, background_color, 618 | sheet_rows, sheet_cols, layout_only, yolov8_path, yunet_path, rmbg_path, size_config, color_config, 619 | compress, change_background, rotate, resize, add_crop_lines, use_csv_size, target_size, size_range_min, size_range_max, 620 | process_btn, output_image, 621 | corrected_output, notification, key_param_tab, advanced_settings_tab, config_management_tab, confirm_advanced_settings,save_final_btn, save_final_path, 622 | save_corrected_btn, save_corrected_path, 623 | size_config_tab, color_config_tab, result_tab, corrected_image_tab, 624 | size_df, color_df, add_size_btn, update_size_btn, 625 | add_color_btn, update_color_btn, config_notification, 626 | size_option_type, language 627 | ] 628 | ) 629 | 630 | confirm_advanced_settings.click( 631 | confirm_advanced_settings_fn, 632 | inputs=[yolov8_path, yunet_path, rmbg_path, size_config, color_config], 633 | outputs=[size_df, color_df, photo_type, photo_sheet_size, preset_color] 634 | ) 635 | 636 | preset_color.change( 637 | update_background_color, 638 | inputs=[preset_color, lang_dropdown], 639 | outputs=[background_color] 640 | ) 641 | 642 | background_color.change( 643 | update_preset_color, 644 | inputs=[background_color, lang_dropdown], 645 | outputs=[preset_color] 646 | ) 647 | 648 | add_size_btn.click(add_size_config, inputs=[size_df], outputs=[size_df, config_notification]) 649 | update_size_btn.click(update_size_config, inputs=[size_df], outputs=[size_df, config_notification]) 650 | 651 | add_color_btn.click(add_color_config, inputs=[color_df], outputs=[color_df, config_notification]) 652 | update_color_btn.click(update_color_config, inputs=[color_df], outputs=[color_df, config_notification]) 653 | 654 | use_csv_size.change( 655 | fn=update_size_input_visibility, 656 | inputs=[use_csv_size, size_option_type, language], 657 | outputs=[size_option_type, target_size, size_range_min, size_range_max] 658 | ) 659 | 660 | size_option_type.change( 661 | fn=update_size_input_visibility, 662 | inputs=[use_csv_size, size_option_type, language], 663 | outputs=[size_option_type, target_size, size_range_min, size_range_max] 664 | ) 665 | 666 | process_btn.click( 667 | process_and_display_wrapper, 668 | inputs=[input_image, yolov8_path, yunet_path, rmbg_path, size_config, color_config, 669 | photo_type, photo_sheet_size, background_color, compress, change_background, 670 | rotate, resize, sheet_rows, sheet_cols, layout_only, add_crop_lines, 671 | target_size, size_range_min, size_range_max, use_csv_size], 672 | outputs=[output_image, corrected_output] 673 | ) 674 | 675 | save_final_btn.click( 676 | save_image_handler, 677 | inputs=[output_image, save_final_path, lang_dropdown, photo_type, photo_sheet_size, background_color], 678 | outputs=[notification] 679 | ) 680 | 681 | save_corrected_btn.click( 682 | save_image_handler, 683 | inputs=[corrected_output, save_corrected_path, lang_dropdown, photo_type, photo_sheet_size, background_color], 684 | outputs=[notification] 685 | ) 686 | 687 | return demo 688 | 689 | if __name__ == "__main__": 690 | parser = argparse.ArgumentParser(description="LiYing Photo Processing System") 691 | parser.add_argument("--lang", type=str, choices=['en', 'zh'], default=get_language(), help="Specify the language (en/zh)") 692 | args = parser.parse_args() 693 | 694 | initial_language = args.lang 695 | demo = create_demo(initial_language) 696 | demo.launch(share=False, server_name="127.0.0.1", server_port=7860) 697 | -------------------------------------------------------------------------------- /src/webui/i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "LiYing Photo Processing System", 3 | "upload_photo": "Upload Photo", 4 | "photo_type": "Photo Type", 5 | "photo_sheet_size": "Photo Sheet Size", 6 | "background_color": "Background Color", 7 | "sheet_rows": "Sheet Rows", 8 | "sheet_cols": "Sheet Columns", 9 | "compress": "Compress", 10 | "change_background": "Change Background", 11 | "rotate": "Rotate 90°", 12 | "resize": "Resize", 13 | "process_btn": "Process Image", 14 | "result": "Result", 15 | "language": "Language", 16 | "advanced_settings": "Advanced Settings", 17 | "yolov8_path": "YOLOv8 Model Path", 18 | "yunet_path": "YuNet Model Path", 19 | "rmbg_path": "RMBG Model Path", 20 | "key_param": "Key Parameters", 21 | "final_image": "Final Image", 22 | "corrected_image": "Corrected Image", 23 | "notification": "Notification", 24 | "size_config": "Size Config", 25 | "color_config": "Color Config", 26 | "preset_color": "Preset Color", 27 | "custom_color": "Custom", 28 | "layout_only": "Layout only (no background change)", 29 | "config_management": "Configuration Management", 30 | "size_config_table": "Size Configuration Table", 31 | "color_config_table": "Color Configuration Table", 32 | "add_size": "Add Size", 33 | "update_size": "Update Size", 34 | "add_color": "Add Color", 35 | "update_color": "Update Color", 36 | "config_notification": "Configuration Notification", 37 | "size_config_updated": "Size configuration updated successfully", 38 | "color_config_updated": "Color configuration updated successfully", 39 | "confirm_settings": "Confirmation of settings", 40 | "save_size": "Save Size Configs", 41 | "save_color": "Save Color Configs", 42 | "size_config_row_added": "New row added to size configuration", 43 | "color_config_row_added": "New row added to color configuration", 44 | "add_crop_lines": "Add crop lines", 45 | "save_image": "Save Image", 46 | "save_corrected": "Save Corrected Image", 47 | "save_path": "Save Path", 48 | "no_image_to_save": "No image to save", 49 | "image_saved_success": "Image saved successfully at {path}", 50 | "image_save_error": "Error saving image: {error}", 51 | "target_size": "Target File Size (KB)", 52 | "size_range_min": "Min File Size (KB)", 53 | "size_range_max": "Max File Size (KB)", 54 | "use_csv_size": "Use size limits from CSV", 55 | "size_range_error": "Min size must be less than max size", 56 | "size_params_conflict": "Both target size and size range provided. Using target size.", 57 | "size_input_option": "Size Input Method", 58 | "target_size_radio": "Target Size", 59 | "size_range_radio": "Size Range" 60 | } 61 | -------------------------------------------------------------------------------- /src/webui/i18n/zh.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "LiYing 照片处理系统", 3 | "upload_photo": "上传照片", 4 | "photo_type": "照片类型", 5 | "photo_sheet_size": "照片表格尺寸", 6 | "background_color": "背景颜色", 7 | "sheet_rows": "表格行数", 8 | "sheet_cols": "表格列数", 9 | "compress": "压缩", 10 | "change_background": "替换背景", 11 | "rotate": "旋转90°", 12 | "resize": "调整尺寸", 13 | "process_btn": "处理图像", 14 | "result": "处理结果", 15 | "language": "语言", 16 | "advanced_settings": "高级设置", 17 | "yolov8_path": "YOLOv8 模型路径", 18 | "yunet_path": "YuNet 模型路径", 19 | "rmbg_path": "RMBG 模型路径", 20 | "key_param": "关键参数", 21 | "final_image": "最终图像", 22 | "corrected_image": "修正后图像", 23 | "notification": "通知", 24 | "size_config": "尺寸配置文件", 25 | "color_config": "颜色配置文件", 26 | "preset_color": "预设颜色", 27 | "custom_color": "自定义", 28 | "layout_only": "仅排版(不更换背景)", 29 | "config_management": "配置管理", 30 | "size_config_table": "尺寸配置表", 31 | "color_config_table": "颜色配置表", 32 | "add_size": "添加尺寸", 33 | "update_size": "更新尺寸", 34 | "add_color": "添加颜色", 35 | "update_color": "更新颜色", 36 | "config_notification": "配置通知", 37 | "size_config_updated": "尺寸配置更新成功", 38 | "color_config_updated": "颜色配置更新成功", 39 | "confirm_settings": "确认配置", 40 | "save_size": "保存尺寸配置", 41 | "save_color": "保存颜色配置", 42 | "size_config_row_added": "已添加新的尺寸配置行", 43 | "color_config_row_added": "已添加新的颜色配置行", 44 | "add_crop_lines": "添加裁剪线", 45 | "save_image": "保存图像", 46 | "save_corrected": "保存更正后的图像", 47 | "save_path": "保存路径", 48 | "no_image_to_save": "暂无需要保存的图像", 49 | "image_saved_success": "图像已在 {path} 成功保存", 50 | "image_save_error": "保存图像出错: {error}", 51 | "target_size": "目标文件大小 (KB)", 52 | "size_range_min": "最小文件大小 (KB)", 53 | "size_range_max": "最大文件大小 (KB)", 54 | "use_csv_size": "使用CSV中的文件大小限制", 55 | "size_range_error": "最小大小必须小于最大大小", 56 | "size_params_conflict": "同时提供了目标大小和大小范围。将使用目标大小。", 57 | "size_input_option": "大小输入方式", 58 | "target_size_radio": "目标大小", 59 | "size_range_radio": "大小范围" 60 | } 61 | -------------------------------------------------------------------------------- /tests/test_liying.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | import sys 4 | import cv2 5 | import numpy as np 6 | import json 7 | import warnings 8 | from PIL import Image 9 | 10 | # Add project root directory to Python path 11 | project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 12 | sys.path.insert(0, project_root) 13 | sys.path.insert(0, os.path.join(project_root, 'src')) 14 | sys.path.insert(0, os.path.join(project_root, 'src', 'tool')) 15 | 16 | from src.tool.ImageProcessor import ImageProcessor 17 | from src.tool.PhotoSheetGenerator import PhotoSheetGenerator 18 | from src.tool.PhotoRequirements import PhotoRequirements 19 | from src.tool.ConfigManager import ConfigManager 20 | 21 | class TestLiYing(unittest.TestCase): 22 | 23 | @classmethod 24 | def setUpClass(cls): 25 | cls.test_image_path = os.path.join(project_root, 'images', 'test1.jpg') 26 | cls.output_dir = os.path.join(project_root, 'tests', 'output') 27 | os.makedirs(cls.output_dir, exist_ok=True) 28 | 29 | # Set model paths 30 | cls.yolov8_model_path = os.path.join(project_root, 'src', 'model', 'yolov8n-pose.onnx') 31 | cls.yunet_model_path = os.path.join(project_root, 'src', 'model', 'face_detection_yunet_2023mar.onnx') 32 | cls.rmbg_model_path = os.path.join(project_root, 'src', 'model', 'RMBG-1.4-model.onnx') 33 | 34 | def get_first_valid_photo_size(self, photo_requirements): 35 | photo_types = photo_requirements.list_photo_types() 36 | return photo_types[0] if photo_types else None 37 | 38 | def check_models_exist(self): 39 | missing_models = [] 40 | if not os.path.exists(self.yolov8_model_path): 41 | missing_models.append("YOLOv8") 42 | if not os.path.exists(self.yunet_model_path): 43 | missing_models.append("YuNet") 44 | if not os.path.exists(self.rmbg_model_path): 45 | missing_models.append("RMBG") 46 | return missing_models 47 | 48 | def check_image_dpi(self, image_path): 49 | with Image.open(image_path) as img: 50 | return img.info.get('dpi') 51 | 52 | def test_image_processor(self): 53 | missing_models = self.check_models_exist() 54 | if missing_models: 55 | warnings.warn(f"Skipping image processing test: Missing model files {', '.join(missing_models)}") 56 | return 57 | 58 | photo_requirements = PhotoRequirements(language='en') 59 | 60 | processor = ImageProcessor(self.test_image_path, 61 | yolov8_model_path=self.yolov8_model_path, 62 | yunet_model_path=self.yunet_model_path, 63 | RMBG_model_path=self.rmbg_model_path) 64 | processor.photo_requirements_detector = photo_requirements 65 | 66 | # Test crop and correct 67 | processor.crop_and_correct_image() 68 | self.assertIsNotNone(processor.photo.image) 69 | 70 | # Test background change 71 | processor.change_background([255, 0, 0]) # Red background, using list 72 | self.assertIsNotNone(processor.photo.image) 73 | 74 | # Test resize 75 | first_photo_size = self.get_first_valid_photo_size(photo_requirements) 76 | self.assertIsNotNone(first_photo_size, "No valid photo size found") 77 | processor.resize_image(first_photo_size) 78 | self.assertIsNotNone(processor.photo.image) 79 | 80 | # Save processed image 81 | output_path = os.path.join(self.output_dir, 'processed_image.jpg') 82 | processor.save_photos(output_path) 83 | self.assertTrue(os.path.exists(output_path)) 84 | 85 | # Check DPI of saved image 86 | dpi = self.check_image_dpi(output_path) 87 | self.assertIsNotNone(dpi, "DPI information should be present in the saved image") 88 | self.assertEqual(dpi[0], dpi[1], "Horizontal and vertical DPI should be the same") 89 | 90 | def test_photo_sheet_generator(self): 91 | missing_models = self.check_models_exist() 92 | if missing_models: 93 | warnings.warn(f"Skipping photo sheet generation test: Missing model files {', '.join(missing_models)}") 94 | return 95 | 96 | photo_requirements = PhotoRequirements(language='en') 97 | 98 | processor = ImageProcessor(self.test_image_path, 99 | yolov8_model_path=self.yolov8_model_path, 100 | yunet_model_path=self.yunet_model_path, 101 | RMBG_model_path=self.rmbg_model_path) 102 | processor.photo_requirements_detector = photo_requirements 103 | processor.crop_and_correct_image() 104 | 105 | first_photo_size = self.get_first_valid_photo_size(photo_requirements) 106 | self.assertIsNotNone(first_photo_size, "No valid photo size found") 107 | processor.resize_image(first_photo_size) 108 | 109 | # Use size from configuration 110 | sheet_sizes = photo_requirements.config_manager.get_sheet_sizes() 111 | first_sheet_size = next(iter(sheet_sizes)) 112 | sheet_config = photo_requirements.config_manager.get_size_config(first_sheet_size) 113 | dpi = sheet_config.get('Resolution', 300) 114 | generator = PhotoSheetGenerator((sheet_config['ElectronicWidth'], sheet_config['ElectronicHeight']), dpi=dpi) 115 | sheet = generator.generate_photo_sheet(processor.photo.image, 2, 2) 116 | 117 | self.assertIsNotNone(sheet) 118 | self.assertEqual(sheet.shape[:2], (sheet_config['ElectronicHeight'], sheet_config['ElectronicWidth'])) 119 | 120 | output_path = os.path.join(self.output_dir, 'photo_sheet.jpg') 121 | generator.save_photo_sheet(sheet, output_path) 122 | self.assertTrue(os.path.exists(output_path)) 123 | 124 | # Check DPI of saved photo sheet 125 | sheet_dpi = self.check_image_dpi(output_path) 126 | self.assertIsNotNone(sheet_dpi, "DPI information should be present in the saved photo sheet") 127 | self.assertEqual(sheet_dpi[0], sheet_dpi[1], "Horizontal and vertical DPI should be the same") 128 | self.assertEqual(sheet_dpi[0], dpi, f"Photo sheet DPI should be {dpi}") 129 | 130 | def test_photo_requirements(self): 131 | requirements = PhotoRequirements(language='en') 132 | first_photo_size = self.get_first_valid_photo_size(requirements) 133 | self.assertIsNotNone(first_photo_size, "No valid photo size found") 134 | 135 | # Test getting resize image list 136 | size_info = requirements.get_resize_image_list(first_photo_size) 137 | self.assertIsInstance(size_info, dict) 138 | self.assertIn('width', size_info) 139 | self.assertIn('height', size_info) 140 | self.assertIn('electronic_size', size_info) 141 | self.assertIn('print_size', size_info) 142 | self.assertIn('resolution', size_info) 143 | 144 | def test_config_manager(self): 145 | # Initialize ConfigManager with Chinese 146 | config_manager = ConfigManager(language='zh') 147 | config_manager.load_configs() 148 | 149 | # Test Chinese configuration 150 | photo_size_configs_zh = config_manager.get_photo_size_configs() 151 | self.assertIsInstance(photo_size_configs_zh, dict) 152 | self.assertGreater(len(photo_size_configs_zh), 0) 153 | 154 | sheet_size_configs_zh = config_manager.get_sheet_size_configs() 155 | self.assertIsInstance(sheet_size_configs_zh, dict) 156 | self.assertGreater(len(sheet_size_configs_zh), 0) 157 | 158 | # Switch to English 159 | config_manager.switch_language('en') 160 | 161 | # Test English configuration 162 | photo_size_configs_en = config_manager.get_photo_size_configs() 163 | self.assertIsInstance(photo_size_configs_en, dict) 164 | self.assertGreater(len(photo_size_configs_en), 0) 165 | 166 | sheet_size_configs_en = config_manager.get_sheet_size_configs() 167 | self.assertIsInstance(sheet_size_configs_en, dict) 168 | self.assertGreater(len(sheet_size_configs_en), 0) 169 | 170 | # Check if some common sizes exist 171 | common_sizes_zh = ['一寸', '二寸 (证件照)', '五寸', '六寸'] 172 | common_sizes_en = ['One Inch', 'Two Inch (ID Photo)', 'Five Inch', 'Six Inch'] 173 | 174 | # Switch back to Chinese for checking Chinese sizes 175 | config_manager.switch_language('zh') 176 | for size_zh in common_sizes_zh: 177 | self.assertIn(size_zh, photo_size_configs_zh, f"Chinese config should contain {size_zh}") 178 | 179 | # Switch to English for checking English sizes 180 | config_manager.switch_language('en') 181 | for size_en in common_sizes_en: 182 | self.assertTrue(any(size_en.lower() in key.lower() for key in photo_size_configs_en.keys()), 183 | f"English config should contain a key with {size_en}") 184 | 185 | # Test language switching functionality 186 | self.assertEqual(config_manager.language, 'en') 187 | config_manager.switch_language('zh') 188 | self.assertEqual(config_manager.language, 'zh') 189 | 190 | # Test if configuration file paths are correctly updated 191 | self.assertTrue(config_manager.size_file.endswith('size_zh.csv')) 192 | self.assertTrue(config_manager.color_file.endswith('color_zh.csv')) 193 | 194 | # Test i18n JSON files 195 | i18n_dir = os.path.join(project_root, 'src', 'webui', 'i18n') 196 | 197 | with open(os.path.join(i18n_dir, 'en.json'), 'r', encoding='utf-8') as f: 198 | en_i18n = json.load(f) 199 | 200 | with open(os.path.join(i18n_dir, 'zh.json'), 'r', encoding='utf-8') as f: 201 | zh_i18n = json.load(f) 202 | 203 | # Check if i18n files have the same keys 204 | self.assertEqual(set(en_i18n.keys()), set(zh_i18n.keys())) 205 | 206 | # Check if at least one value is different 207 | different_values = [key for key in en_i18n.keys() if en_i18n[key] != zh_i18n[key]] 208 | self.assertGreater(len(different_values), 0, "English and Chinese i18n files should have different translations") 209 | 210 | 211 | if __name__ == '__main__': 212 | unittest.main() 213 | -------------------------------------------------------------------------------- /tests/test_photo_sizes.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import csv 3 | import os 4 | import sys 5 | 6 | # Add project root directory to Python path 7 | project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 8 | sys.path.insert(0, project_root) 9 | sys.path.insert(0, os.path.join(project_root, 'src')) 10 | sys.path.insert(0, os.path.join(project_root, 'src', 'tool')) 11 | 12 | def inch_to_cm(inch): 13 | return inch * 2.54 14 | 15 | def cm_to_inch(cm): 16 | return cm / 2.54 17 | 18 | def calculate_electronic_size(print_width, print_height, resolution): 19 | electronic_width = round(print_width / inch_to_cm(1) * resolution) 20 | electronic_height = round(print_height / inch_to_cm(1) * resolution) 21 | return electronic_width, electronic_height 22 | 23 | class TestPhotoSizes(unittest.TestCase): 24 | def setUp(self): 25 | self.csv_path = os.path.join('..\data', 'size_zh.csv') 26 | self.tolerance_pixels = 1 27 | self.tolerance_cm = 0.1 28 | 29 | def test_photo_sizes(self): 30 | with open(self.csv_path, 'r', encoding='utf-8') as csvfile: 31 | reader = csv.DictReader(csvfile) 32 | for row in reader: 33 | with self.subTest(name=row['Name']): 34 | self.validate_row(row) 35 | 36 | def validate_row(self, row): 37 | name = row['Name'] 38 | print_width = float(row['PrintWidth']) if row['PrintWidth'] else None 39 | print_height = float(row['PrintHeight']) if row['PrintHeight'] else None 40 | electronic_width = int(row['ElectronicWidth']) if row['ElectronicWidth'] else None 41 | electronic_height = int(row['ElectronicHeight']) if row['ElectronicHeight'] else None 42 | resolution = int(row['Resolution']) if row['Resolution'] else 300 43 | 44 | if print_width and print_height and electronic_width and electronic_height: 45 | self.validate_electronic_size(name, print_width, print_height, electronic_width, electronic_height, resolution) 46 | elif electronic_width and electronic_height: 47 | self.validate_print_size(name, electronic_width, electronic_height, print_width, print_height, resolution) 48 | else: 49 | self.fail(f"Insufficient data for {name}") 50 | 51 | def validate_electronic_size(self, name, print_width, print_height, electronic_width, electronic_height, resolution): 52 | calculated_width, calculated_height = calculate_electronic_size(print_width, print_height, resolution) 53 | 54 | width_diff = abs(calculated_width - electronic_width) 55 | height_diff = abs(calculated_height - electronic_height) 56 | 57 | self.assertLessEqual(width_diff, self.tolerance_pixels, 58 | f"Width mismatch for {name}: calculated {calculated_width}, actual {electronic_width}") 59 | self.assertLessEqual(height_diff, self.tolerance_pixels, 60 | f"Height mismatch for {name}: calculated {calculated_height}, actual {electronic_height}") 61 | 62 | def validate_print_size(self, name, electronic_width, electronic_height, print_width, print_height, resolution): 63 | calculated_print_width = round(cm_to_inch(electronic_width / resolution * inch_to_cm(1)), 1) 64 | calculated_print_height = round(cm_to_inch(electronic_height / resolution * inch_to_cm(1)), 1) 65 | 66 | if print_width and print_height: 67 | width_diff = abs(calculated_print_width - print_width) 68 | height_diff = abs(calculated_print_height - print_height) 69 | 70 | self.assertLessEqual(width_diff, self.tolerance_cm, 71 | f"Print width mismatch for {name}: calculated {calculated_print_width}, actual {print_width}") 72 | self.assertLessEqual(height_diff, self.tolerance_cm, 73 | f"Print height mismatch for {name}: calculated {calculated_print_height}, actual {print_height}") 74 | else: 75 | self.fail(f"Missing print size for {name}: Calculated print size: {calculated_print_width}x{calculated_print_height}") 76 | 77 | if __name__ == '__main__': 78 | unittest.main() 79 | --------------------------------------------------------------------------------