├── .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 | 
16 |
17 | ### Demonstration
18 |
19 | |  |  |  |
20 | | ----------------------------- | ---------------------------- | ---------------------------- |
21 | | (1-inch on 5-inch photo paper - 3x3) | (2-inch on 5-inch photo paper - 2x2) | (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 |
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 | 
16 |
17 | ### 效果展示
18 |
19 | |  |  |  |
20 | | ----------------------------- | ---------------------------- | ---------------------------- |
21 | | (1寸-5寸相片纸-3*3) | (2寸-5寸相片纸-2*2) | (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 |
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 |
--------------------------------------------------------------------------------