├── license.txt
├── License
├── Y.cer
├── Y.pfx
├── Y.pvk
├── Y.spc
├── certmgr.exe
└── cert2spc.exe
├── resources
├── img
│ ├── Setup.png
│ ├── icon.ico
│ ├── setup.ico
│ ├── list
│ │ ├── logo.png
│ │ ├── 媒体导入.svg
│ │ ├── 图像识别.svg
│ │ ├── 智能整理.svg
│ │ ├── 属性写入.svg
│ │ └── 文件去重.svg
│ ├── page_3
│ │ ├── 对比.jpg
│ │ └── 对比空状态.svg
│ ├── activity
│ │ └── QQ_名片.png
│ ├── 窗口控制
│ │ ├── 最小化.svg
│ │ ├── 关闭.svg
│ │ ├── 最大化.svg
│ │ ├── 关闭作者.svg
│ │ ├── 还原.svg
│ │ ├── 搜索.svg
│ │ ├── github.svg
│ │ ├── 服务.svg
│ │ ├── 设置.svg
│ │ └── microsoft.svg
│ ├── page_2
│ │ └── 位置.svg
│ ├── page_4
│ │ ├── 位置.svg
│ │ ├── 星级_暗.svg
│ │ └── 星级_亮.svg
│ ├── page_0
│ │ ├── 导入文件夹.svg
│ │ └── 空状态.svg
│ └── 头标
│ │ ├── 头标-银色体验会员.svg
│ │ ├── 头标-铂金体验会员.svg
│ │ ├── 头标-银色标准会员.svg
│ │ ├── 头标-紫银高级会员.svg
│ │ └── 头标-荣耀超级会员.svg
├── exiftool
│ ├── exiftool.exe
│ ├── exiftool(-k).exe
│ ├── exiftool_files
│ │ ├── perl.exe
│ │ ├── perl532.dll
│ │ ├── liblzma-5__.dll
│ │ ├── libstdc++-6.dll
│ │ ├── libgcc_s_seh-1.dll
│ │ ├── libwinpthread-1.dll
│ │ ├── Licenses_Strawberry_Perl.zip
│ │ └── readme_windows.txt
│ └── README.txt
├── stylesheet
│ └── menu.setStyleSheet.css
└── json
│ ├── camera_brand_model.json
│ ├── lens_model.json
│ └── camera_lens_mapping.json
├── .idea
├── vcs.xml
├── .gitignore
├── inspectionProfiles
│ ├── profiles_settings.xml
│ └── Project_Default.xml
├── modules.xml
└── misc.xml
├── LeafSort_version_info.txt
├── App.spec
├── App.py
├── .gitignore
├── LeafSort封装脚本.iss
├── update_dialog.py
├── main_window.py
├── UI_UpdateDialog.py
├── UI_UpdateDialog.ui
├── README.md
├── file_deduplication.py
├── add_folder.py
├── config_manager.py
├── README_EN.md
└── common.py
/license.txt:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YangShengzhou03/LeafSort/HEAD/license.txt
--------------------------------------------------------------------------------
/License/Y.cer:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YangShengzhou03/LeafSort/HEAD/License/Y.cer
--------------------------------------------------------------------------------
/License/Y.pfx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YangShengzhou03/LeafSort/HEAD/License/Y.pfx
--------------------------------------------------------------------------------
/License/Y.pvk:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YangShengzhou03/LeafSort/HEAD/License/Y.pvk
--------------------------------------------------------------------------------
/License/Y.spc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YangShengzhou03/LeafSort/HEAD/License/Y.spc
--------------------------------------------------------------------------------
/License/certmgr.exe:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YangShengzhou03/LeafSort/HEAD/License/certmgr.exe
--------------------------------------------------------------------------------
/License/cert2spc.exe:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YangShengzhou03/LeafSort/HEAD/License/cert2spc.exe
--------------------------------------------------------------------------------
/resources/img/Setup.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YangShengzhou03/LeafSort/HEAD/resources/img/Setup.png
--------------------------------------------------------------------------------
/resources/img/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YangShengzhou03/LeafSort/HEAD/resources/img/icon.ico
--------------------------------------------------------------------------------
/resources/img/setup.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YangShengzhou03/LeafSort/HEAD/resources/img/setup.ico
--------------------------------------------------------------------------------
/resources/img/list/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YangShengzhou03/LeafSort/HEAD/resources/img/list/logo.png
--------------------------------------------------------------------------------
/resources/img/page_3/对比.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YangShengzhou03/LeafSort/HEAD/resources/img/page_3/对比.jpg
--------------------------------------------------------------------------------
/resources/exiftool/exiftool.exe:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YangShengzhou03/LeafSort/HEAD/resources/exiftool/exiftool.exe
--------------------------------------------------------------------------------
/resources/img/activity/QQ_名片.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YangShengzhou03/LeafSort/HEAD/resources/img/activity/QQ_名片.png
--------------------------------------------------------------------------------
/resources/exiftool/exiftool(-k).exe:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YangShengzhou03/LeafSort/HEAD/resources/exiftool/exiftool(-k).exe
--------------------------------------------------------------------------------
/resources/exiftool/exiftool_files/perl.exe:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YangShengzhou03/LeafSort/HEAD/resources/exiftool/exiftool_files/perl.exe
--------------------------------------------------------------------------------
/resources/exiftool/exiftool_files/perl532.dll:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YangShengzhou03/LeafSort/HEAD/resources/exiftool/exiftool_files/perl532.dll
--------------------------------------------------------------------------------
/resources/exiftool/exiftool_files/liblzma-5__.dll:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YangShengzhou03/LeafSort/HEAD/resources/exiftool/exiftool_files/liblzma-5__.dll
--------------------------------------------------------------------------------
/resources/exiftool/exiftool_files/libstdc++-6.dll:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YangShengzhou03/LeafSort/HEAD/resources/exiftool/exiftool_files/libstdc++-6.dll
--------------------------------------------------------------------------------
/resources/exiftool/exiftool_files/libgcc_s_seh-1.dll:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YangShengzhou03/LeafSort/HEAD/resources/exiftool/exiftool_files/libgcc_s_seh-1.dll
--------------------------------------------------------------------------------
/resources/exiftool/exiftool_files/libwinpthread-1.dll:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YangShengzhou03/LeafSort/HEAD/resources/exiftool/exiftool_files/libwinpthread-1.dll
--------------------------------------------------------------------------------
/resources/exiftool/exiftool_files/Licenses_Strawberry_Perl.zip:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YangShengzhou03/LeafSort/HEAD/resources/exiftool/exiftool_files/Licenses_Strawberry_Perl.zip
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Editor-based HTTP Client requests
5 | /httpRequests/
6 | # Datasource local storage ignored files
7 | /dataSources/
8 | /dataSources.local.xml
9 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/profiles_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/resources/img/窗口控制/最小化.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/resources/exiftool/README.txt:
--------------------------------------------------------------------------------
1 |
2 | ExifTool package for 64-bit Windows
3 | ___________________________________
4 |
5 | Double-click on "exiftool(-k).exe" to read the application documentation, or
6 | drag-and-drop files to extract metadata.
7 |
8 | For command-line use, rename to "exiftool.exe".
9 |
10 | Run directly in from the exiftool-##.##_64 folder, or copy the .exe and the
11 | "exiftool_files" folder to wherever you want (preferably somewhere in your
12 | PATH) to run from there.
13 |
14 | See https://exiftool.org/install.html for more installation instructions.
15 |
--------------------------------------------------------------------------------
/resources/img/窗口控制/关闭.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/exiftool/exiftool_files/readme_windows.txt:
--------------------------------------------------------------------------------
1 | This readme is for the Windows ExifTool package by Oliver Betz.
2 |
3 | This package is intended to avoid problems of the PAR packed original ExifTool for Windows.
4 |
5 | It consists of:
6 |
7 | * ExifTool by Phil Harvey https://exiftool.org/ resp. https://github.com/exiftool/exiftool
8 | * Strawberry Perl https://strawberryperl.com/
9 | * Tiny launcher by Oliver Betz https://oliverbetz.de/pages/Artikel/ExifTool-for-Windows
10 |
11 | See https://exiftool.org/ and https://strawberryperl.com/ for the license of ExifTool and Strawberry Perl.
12 | The launcher is licensed under the https://creativecommons.org/publicdomain/zero/1.0/legalcode CC0 license
13 |
14 | I make no warranties about the package, and disclaim liability for all uses of the package, to the fullest extent permitted by applicable law.
15 |
16 | Oliver Betz
17 |
--------------------------------------------------------------------------------
/resources/img/窗口控制/最大化.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/img/窗口控制/关闭作者.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/img/page_2/位置.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/img/page_4/位置.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/img/page_0/导入文件夹.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/LeafSort_version_info.txt:
--------------------------------------------------------------------------------
1 | VSVersionInfo(
2 | ffi=FixedFileInfo(
3 | filevers=(2, 0, 2, 0),
4 | prodvers=(2, 0, 2, 0),
5 | mask=0x3f,
6 | flags=0x0,
7 | OS=0x4,
8 | fileType=0x1,
9 | subtype=0x0,
10 | date=(0, 0)
11 | ),
12 | kids=[
13 | StringFileInfo(
14 | [
15 | StringTable(
16 | '040904B0',
17 | [
18 | StringStruct('CompanyName', 'Yangshengzhou'),
19 | StringStruct('FileDescription', 'LeafSort'),
20 | StringStruct('FileVersion', '2.0.2.0'),
21 | StringStruct('InternalName', 'LeafSort'),
22 | StringStruct('LegalCopyright', 'Copyright © 2025 YangShengzhou 版权所有'),
23 | StringStruct('OriginalFilename', 'LeafSort.exe'),
24 | StringStruct('ProductName', 'LeafSort'),
25 | StringStruct('ProductVersion', '2.0.2.0')
26 | ]
27 | )
28 | ]
29 | ),
30 | VarFileInfo(
31 | [
32 | VarStruct('Translation', [2052, 1200])
33 | ]
34 | )
35 | ]
36 | )
--------------------------------------------------------------------------------
/resources/img/窗口控制/还原.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/stylesheet/menu.setStyleSheet.css:
--------------------------------------------------------------------------------
1 | QMenu {
2 | background: rgba(255, 255, 255, 0.98);
3 | padding: 2px;
4 | /* 使用多层边框模拟阴影效果 */
5 | border: 1px solid rgba(0, 0, 0, 0.1);
6 | font-size: 14px;
7 | font-family: 'Microsoft YaHei Light', 'Segoe UI', 'Microsoft YaHei', sans-serif;
8 | border-radius: 6px;
9 | margin: 4px;
10 | }
11 |
12 | QMenu::item {
13 | padding: 10px 20px;
14 | margin: 2px 0;
15 | color: #2c3e50;
16 | background: transparent;
17 | border-radius: 6px;
18 | transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
19 | }
20 |
21 | QMenu::item:hover {
22 | background: #691BFD;
23 | color: #ffffff;
24 | /* 移除不支持的box-shadow和transform属性 */
25 | }
26 |
27 | QMenu::item:selected {
28 | background: #691BFD;
29 | color: #ffffff;
30 | /* 使用边框代替box-shadow */
31 | border: 1px solid rgba(0, 120, 255, 0.2);
32 | }
33 |
34 | QMenu::separator {
35 | height: 1px;
36 | background: linear-gradient(to right, transparent, rgba(0, 0, 0, 0.1), transparent);
37 | margin: 8px 12px;
38 | border: none;
39 | }
40 |
41 | /* PyQt6不支持CSS动画,移除keyframes定义 */
--------------------------------------------------------------------------------
/resources/img/list/媒体导入.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/App.spec:
--------------------------------------------------------------------------------
1 | block_cipher = None
2 |
3 | a = Analysis(
4 | ['App.py'],
5 | pathex=[],
6 | binaries=[],
7 | datas=[
8 | ('resources', 'resources'),
9 | (r'C:\Users\YangShengzhou\AppData\Local\ms-playwright\chromium_headless_shell-1194',
10 | r'playwright\driver\package\.local-browsers\chromium_headless_shell-1194')
11 | ],
12 | hiddenimports=['numpy.core._multiarray_tests', 'numpy.core._multiarray_umath'],
13 | hookspath=[],
14 | hooksconfig={},
15 | runtime_hooks=[],
16 | excludes=[],
17 | noarchive=False
18 | )
19 | pyz = PYZ(a.pure)
20 | exe = EXE(
21 | pyz,
22 | a.scripts,
23 | [],
24 | exclude_binaries=True,
25 | name='LeafSort',
26 | debug=False,
27 | bootloader_ignore_signals=False,
28 | strip=False,
29 | upx=True,
30 | console=False,
31 | disable_windowed_traceback=False,
32 | argv_emulation=False,
33 | target_arch=None,
34 | codesign_identity=None,
35 | entitlements_file=None,
36 | version='LeafSort_version_info.txt',
37 | icon='resources\\img\\icon.ico',
38 | optimize=0
39 | )
40 | coll = COLLECT(
41 | exe,
42 | a.binaries,
43 | a.datas,
44 | strip=False,
45 | upx=True,
46 | upx_exclude=[],
47 | name='App'
48 | )
--------------------------------------------------------------------------------
/resources/img/page_4/星级_暗.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/img/list/图像识别.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/img/list/智能整理.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/img/窗口控制/搜索.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/img/窗口控制/github.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/img/list/属性写入.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/img/page_4/星级_亮.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/img/窗口控制/服务.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/App.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import logging
3 | import traceback
4 | import socket
5 | from PyQt6.QtWidgets import QApplication, QMessageBox
6 | from PyQt6.QtCore import QCoreApplication
7 | from main_window import MainWindow
8 |
9 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
10 | handlers=[logging.StreamHandler()])
11 | logger = logging.getLogger(__name__)
12 |
13 |
14 | def handle_exception(exc_type, exc_value, exc_traceback):
15 | if issubclass(exc_type, KeyboardInterrupt):
16 | sys.__excepthook__(exc_type, exc_value, exc_traceback)
17 | return
18 | logging.critical("Unhandled exception", exc_info=(exc_type, exc_value, exc_traceback))
19 | app = QApplication.instance()
20 | if app:
21 | error_message = ''.join(traceback.format_exception(exc_type, exc_value, exc_traceback))
22 | print(f"Error: {exc_type.__name__}: {exc_value}")
23 | print(error_message)
24 | msg = QMessageBox()
25 | msg.setIcon(QMessageBox.Icon.Critical)
26 | msg.setWindowTitle("Application Error")
27 | msg.setText("An unexpected error occurred. The application may need to close.")
28 | msg.setInformativeText(f"{exc_type.__name__}: {exc_value}")
29 | msg.setDetailedText(error_message)
30 | msg.exec()
31 |
32 |
33 | def main():
34 | try:
35 | QCoreApplication.setApplicationName("LeafSort(轻羽媒体整理)")
36 | QCoreApplication.setApplicationVersion("2.0.2")
37 | try:
38 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
39 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
40 | sock.bind(('127.0.0.1', 12345))
41 | except socket.error:
42 | logging.info("Application already running")
43 | return
44 | app = QApplication(sys.argv)
45 | window = MainWindow()
46 | window.show()
47 | sys.exit(app.exec())
48 | except Exception as e:
49 | logging.error(f"Failed to start application: {str(e)}")
50 | print(f"Start error: {str(e)}")
51 | traceback.print_exc()
52 | QMessageBox.critical(None, "致命错误", f"应用程序启动失败: {str(e)}")
53 | sys.exit(1)
54 |
55 |
56 | if __name__ == "__main__":
57 | sys.excepthook = handle_exception
58 | main()
59 |
--------------------------------------------------------------------------------
/.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 | pip-wheel-metadata/
24 | share/python-wheels/
25 | *.egg-info/
26 | .installed.cfg
27 | *.egg
28 | MANIFEST
29 |
30 | # PyInstaller
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 | .nox/
42 | .coverage
43 | .coverage.*
44 | .cache
45 | nosetests.xml
46 | coverage.xml
47 | *.cover
48 | *.py,cover
49 | .hypothesis/
50 | .pytest_cache/
51 |
52 | # Translations
53 | *.mo
54 | *.pot
55 |
56 | # Django stuff:
57 | *.log
58 | local_settings.py
59 | db.sqlite3
60 | db.sqlite3-journal
61 |
62 | # Flask stuff:
63 | instance/
64 | .webassets-cache
65 |
66 | # Scrapy stuff:
67 | .scrapy
68 |
69 | # Sphinx documentation
70 | docs/_build/
71 |
72 | # PyBuilder
73 | target/
74 |
75 | # Jupyter Notebook
76 | .ipynb_checkpoints
77 |
78 | # IPython
79 | profile_default/
80 | ipython_config.py
81 |
82 | # pyenv
83 | .python-version
84 |
85 | # pipenv
86 | Pipfile.lock
87 |
88 | # PEP 582
89 | __pypackages__/
90 |
91 | # Celery stuff
92 | celerybeat-schedule
93 | celerybeat.pid
94 |
95 | # SageMath parsed files
96 | *.sage.py
97 |
98 | # Environments
99 | .env
100 | .venv
101 | env/
102 | venv/
103 | ENV/
104 | env.bak/
105 | venv.bak/
106 |
107 | # Spyder project settings
108 | .spyderproject
109 | .spyproject
110 |
111 | # Rope project settings
112 | .ropeproject
113 |
114 | # mkdocs documentation
115 | /site
116 |
117 | # mypy
118 | .mypy_cache/
119 | .dmypy.json
120 | dmypy.json
121 |
122 | # Pyre type checker
123 | .pyre/
124 |
125 | # IDE - VSCode
126 | .vscode/
127 |
128 | # IDE - PyCharm
129 | .idea/
130 |
131 | # IDE - Eclipse
132 | .project
133 | .cproject
134 | .settings/
135 |
136 | # Windows
137 | Thumbs.db
138 | Thumbs.db:encryptable
139 | ehthumbs.db
140 | ehthumbs_vista.db
141 | *.tmp
142 | *.temp
143 | Desktop.ini
144 | $RECYCLE.BIN/
145 |
146 | # macOS
147 | .DS_Store
148 | .AppleDouble
149 | .LSOverride
150 |
151 | # Linux
152 | *~
153 |
154 | # Internal config files
155 | _internal/
156 | _internal/*.json
157 |
--------------------------------------------------------------------------------
/LeafSort封装脚本.iss:
--------------------------------------------------------------------------------
1 | ; 脚本由 Inno Setup 脚本向导 生成!
2 | ; 有关创建 Inno Setup 脚本文件的详细资料请查阅帮助文档!
3 |
4 | #define MyAppName "LeafSort"
5 | #define MyAppVersion "2.0.2.0"
6 | #define MyAppPublisher "Yangshengzhou"
7 | #define MyAppURL "https://gitee.com/Yangshengzhou/leaf-sort"
8 | #define AppSupportURL "https://gitee.com/Yangshengzhou/leaf-sort"
9 | #define MyAppExeName "LeafSort.exe"
10 |
11 | [Setup]
12 | ; 注: AppId的值为单独标识该应用程序。
13 | ; 不要为其他安装程序使用相同的AppId值。
14 | ; (若要生成新的 GUID,可在菜单中点击 "工具|生成 GUID"。)
15 | AppId={{95A610D2-5A75-4C0D-A764-09E8332F534C}}
16 | AppName={#MyAppName}
17 | AppVersion={#MyAppVersion}
18 | ;AppVerName={#MyAppName} {#MyAppVersion}
19 | AppPublisher={#MyAppPublisher}
20 | AppPublisherURL={#MyAppURL}
21 | AppSupportURL={#AppSupportURL}
22 | AppUpdatesURL={#MyAppURL}
23 | VersionInfoVersion=2.0.2.0
24 | DefaultDirName={autopf}\{#MyAppName}
25 | DisableProgramGroupPage=yes
26 | LicenseFile=D:\Code\python\LeafSort\license.txt
27 | ; 以下行取消注释,以在非管理安装模式下运行(仅为当前用户安装)。
28 | ;PrivilegesRequired=lowest
29 | ; 以管理员身份运行
30 | PrivilegesRequired=admin
31 | OutputDir=C:\Users\YangShengzhou\Desktop
32 | OutputBaseFilename=LeafSort Setup
33 | SetupIconFile=D:\code\Python\LeafSort\resources\img\setup.ico
34 | Compression=lzma2/max
35 | SolidCompression=yes
36 | WizardStyle=modern
37 | AppCopyright=© 2025 LeafSort by YangShengzhou.版权所有
38 | UninstallDisplayIcon={app}\_internal\resources\img\icon.ico
39 |
40 |
41 | [UninstallDelete]
42 | Type: files; Name: "{app}\*.*"
43 | Type: dirifempty; Name: "{app}\_internal"
44 | Type: dirifempty; Name: "{app}"
45 |
46 | [Languages]
47 | Name: "chinesesimp"; MessagesFile: "compiler:Languages\ChineseSimplified.isl"
48 |
49 | [Tasks]
50 | Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: checkablealone
51 |
52 | [Files]
53 | Source: "D:\App\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
54 | Source: "D:\App\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
55 | ; 注意: 不要在任何共享系统文件上使用“Flags: ignoreversion”
56 |
57 | [Icons]
58 | Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
59 | Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
60 |
61 | [Run]
62 | ; 安装完成后运行
63 | Filename: "{app}\{#MyAppExeName}"; Description: "立即运行“{#MyAppName}”"; Flags: nowait postinstall skipifsilent runascurrentuser
64 | ; 安装完成后运行 Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
65 |
66 |
--------------------------------------------------------------------------------
/resources/json/camera_brand_model.json:
--------------------------------------------------------------------------------
1 | {
2 | "Canon": ["EOS R6 Mark III", "EOS R3 Mark II", "EOS R5 Mark II", "EOS R5", "EOS R6 Mark II", "EOS R50 V", "EOS R8", "EOS R6", "EOS R3", "EOS R50 Mark II", "EOS R50", "EOS R10 Mark II", "EOS R10", "EOS RP II", "EOS 850D", "EOS 90D"],
3 | "Nikon": ["ZR", "Zf", "Z9 II", "Z8", "Z7 III", "Z6 III", "Z7 II", "Z6 II", "Z50 II", "Z50", "Z30", "Zfc", "Z5", "Z9", "Z6", "Z7"],
4 | "Sony": ["A1 II", "A9 III", "A7C III", "A7R V", "A7S III", "A7 IV", "A7 III", "A7C II", "A7C", "A1", "A6700", "ZV-E10 Mark II", "ZV-E10", "FX30", "FX3", "ZV-1 II"],
5 | "Fujifilm": ["GFX100RF", "X-Half", "X100VII", "X-M5", "X-T5 II", "X-T4", "X-T40", "X-T30 III", "X-T30 II", "X100VI", "X100V", "X-S20", "X-S10", "X-E5", "X-E4", "GFX Eterna"],
6 | "Leica": ["Q3", "Q3 43", "Q2", "M11 Monochrom", "M11", "SL3", "SL2-S", "CL2", "CL", "M10-R", "Q2 Monochrom", "M9-P", "SL2", "TL2", "X Vario", "C-Lux"],
7 | "Panasonic": ["S1R II", "S1H II", "GH7", "GH6", "GH5 II", "G9 II", "G9", "S5 II", "S5 IIX", "S5", "S1", "S1R", "G85", "GX9", "ZS220", "LX100 II"],
8 | "Xiaomi": ["17 Pro Max", "17 Pro", "17", "REDMI Turbo4 Pro", "REDMI K80 Ultra", "Civi6 Pro", "16 Ultra", "16 Pro", "16", "15 Pro", "15", "REDMI Note 13 Pro", "Civi 5", "14 Ultra", "14 Pro", "14"],
9 | "Huawei": ["Mate 80 Pro Max", "Mate 80 Pro", "Mate 80", "Mate 80 RS", "Mate X7", "Mate 70 Air", "nova Flip", "Pura 70 Ultra", "Mate 70 Pro", "Mate 70", "nova 13", "nova 13 Pro", "P60 Pro", "P60", "Mate X6", "Mate 60 Pro"],
10 | "Apple": ["iPhone 17 Pro Max", "iPhone 17 Pro", "iPhone 17", "iPhone Air", "iPhone 16 Pro Max", "iPhone 16 Pro", "iPhone 16 Plus", "iPhone 16", "iPhone SE 3", "iPhone 15 Pro Max", "iPhone 15 Pro", "iPhone 15 Plus", "iPhone 15", "iPhone 14 Pro Max", "iPhone 14 Pro", "iPhone 14"],
11 | "Samsung": ["Galaxy S25 Ultra", "Galaxy S25+", "Galaxy S25", "Galaxy S25 FE", "Galaxy Z Fold7", "Galaxy Z Flip7", "Galaxy Z Fold6", "Galaxy Z Flip6", "Galaxy S24 Ultra", "Galaxy S24+", "Galaxy S24", "Galaxy A55", "Galaxy A35", "Galaxy Tab S9 Ultra", "Galaxy Watch 7", "Galaxy Buds3"],
12 | "OPPO": ["Find X8 Ultra", "Find X8 Pro", "Find X8", "Find X7 Ultra", "Find X7 Pro", "Find X7", "Reno13 Pro+", "Reno13 Pro", "Reno13", "Reno12 Pro", "Reno12", "A2 Pro", "K12", "F25 Pro", "N3 Flip", "Reno13 Lite"],
13 | "vivo": ["X200 Ultra", "X200 Pro", "X200 Pro mini", "X200", "X100 Ultra", "X100 Pro", "X100", "S18 Pro", "S18", "S17 Pro", "S17", "iQOO 12 Pro", "iQOO 12", "T2x", "Y78+", "Y100"],
14 | "OnePlus": ["13", "13T", "Ace 5", "Ace 5 Pro", "12R", "12", "11", "11R", "10 Pro", "10", "9 Pro", "9", "Nord 3", "Nord CE 3", "Ace 3", "Ace 2 Pro"]
15 | }
--------------------------------------------------------------------------------
/update_dialog.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | import requests
4 | from PyQt6 import QtCore, QtGui
5 | from PyQt6.QtCore import QUrl
6 | from PyQt6.QtGui import QDesktopServices
7 | from PyQt6.QtWidgets import QDialog
8 |
9 | from UI_UpdateDialog import Ui_UpdateDialog
10 | from common import get_resource_path
11 |
12 | logger = logging.getLogger(__name__)
13 |
14 | class UpdateDialog(QDialog):
15 | def __init__(self, url, title, content, version="", necessary=False):
16 | super().__init__()
17 | self.ui = Ui_UpdateDialog()
18 | self.ui.setupUi(self)
19 | self.url = url
20 | self.setWindowTitle("轻羽媒体整理通知 - 更新提示")
21 | self.setWindowIcon(QtGui.QIcon(get_resource_path('_internal/resources/img/icon.ico')))
22 | self.setWindowFlags(QtCore.Qt.WindowType.FramelessWindowHint)
23 | self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground)
24 | self.ui.lblTitle.setText(title)
25 | self.ui.lblContent.setText(content)
26 |
27 |
28 | self.ui.btnDownload.clicked.connect(self.download_update)
29 | self.ui.btnCancel.clicked.connect(self.close)
30 |
31 | def download_update(self):
32 | QDesktopServices.openUrl(QUrl(self.url))
33 | self.close()
34 |
35 | def closeEvent(self, event):
36 |
37 | super().closeEvent(event)
38 |
39 | def check_update():
40 | url = 'https://gitee.com/Yangshengzhou/yang-shengzhou/raw/master/LeafSort/versionInfo'
41 | try:
42 | from main_window import version as current_version
43 | logger.info(f"当前应用版本: {current_version}")
44 | except ImportError:
45 | current_version = 2.02
46 |
47 | try:
48 | response = requests.get(url, timeout=5)
49 | response.raise_for_status()
50 |
51 | content = response.text.strip()
52 | info_dict = {}
53 | for line in content.split('\n'):
54 | if ':' in line:
55 | key, value = line.split(':', 1)
56 | info_dict[key.strip()] = value.strip()
57 |
58 | if not all(key in info_dict for key in ['Latest Version', 'Download Link', 'Description']):
59 | raise ValueError("Invalid update information format")
60 |
61 | latest_version_str = info_dict['Latest Version']
62 | download_link = info_dict['Download Link']
63 | description = info_dict['Description']
64 |
65 | try:
66 | latest_version = float(latest_version_str)
67 | except ValueError:
68 | raise ValueError("Invalid version number format")
69 |
70 | if latest_version > current_version:
71 | version_text = f"LeafSort v{latest_version_str}"
72 | dialog = UpdateDialog(download_link, f"发现新版本 {version_text}", description, version_text, False)
73 | dialog.exec()
74 |
75 | except requests.exceptions.RequestException as e:
76 | logger.info(f"网络错误,无法检查更新: {str(e)}")
77 | except Exception as e:
78 | logger.error(f"检查更新时出错: {str(e)}")
79 |
--------------------------------------------------------------------------------
/resources/img/list/文件去重.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/img/窗口控制/设置.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/img/头标/头标-银色体验会员.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/img/头标/头标-铂金体验会员.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/img/头标/头标-银色标准会员.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/img/头标/头标-紫银高级会员.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/img/page_3/对比空状态.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/img/头标/头标-荣耀超级会员.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/main_window.py:
--------------------------------------------------------------------------------
1 | import os
2 | from PyQt6 import QtWidgets, QtCore, QtGui
3 | from PyQt6.QtCore import Qt, QPoint
4 | from Ui_MainWindow import Ui_MainWindow
5 | from add_folder import FolderPage
6 | from smart_arrange import SmartArrangeManager
7 | from write_exif import WriteExifManager
8 | from file_deduplication import FileDeduplicationManager
9 | from update_dialog import check_update
10 | from common import get_resource_path
11 | import logging
12 |
13 | logging.basicConfig(level=logging.INFO)
14 | logger = logging.getLogger(__name__)
15 |
16 | version = 2.02
17 |
18 | class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
19 | def __init__(self):
20 | super().__init__()
21 | self.setupUi(self)
22 | self._drag_position = QPoint()
23 | self.tray_icon = None
24 | self._exit_requested = False
25 | self._setup_ui()
26 | self._initialize_pages()
27 | self._connect_signals()
28 |
29 | try:
30 | check_update()
31 | except Exception as e:
32 | logger.error(f"Error checking for updates: {str(e)}")
33 |
34 | def _initialize_pages(self):
35 | self.folder_page = FolderPage(self)
36 | self.smart_arrange_page = SmartArrangeManager(self, self.folder_page)
37 | self.write_exif_page = WriteExifManager(self, self.folder_page)
38 | self.deduplication_page = FileDeduplicationManager(self, self.folder_page)
39 |
40 | def _setup_ui(self):
41 | self.setWindowTitle("LeafSort")
42 | icon_path = get_resource_path('_internal/resources/img/icon.ico')
43 | if icon_path:
44 | self.setWindowIcon(QtGui.QIcon(icon_path))
45 | self.setWindowFlags(QtCore.Qt.WindowType.FramelessWindowHint)
46 | self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground)
47 | self._setup_system_tray()
48 |
49 | def _setup_system_tray(self):
50 | self.tray_icon = QtWidgets.QSystemTrayIcon(self)
51 | icon_path = get_resource_path('_internal/resources/img/icon.ico')
52 | if icon_path:
53 | self.tray_icon.setIcon(QtGui.QIcon(icon_path))
54 | self.tray_icon.setToolTip("LeafSort")
55 | tray_menu = QtWidgets.QMenu()
56 | action_show = QtGui.QAction("显示窗口", self)
57 | action_show.triggered.connect(self._show_window)
58 | action_exit = QtGui.QAction("退出应用", self)
59 | action_exit.triggered.connect(self._exit_app)
60 | tray_menu.addAction(action_show)
61 | tray_menu.addSeparator()
62 | tray_menu.addAction(action_exit)
63 | css_path = get_resource_path('_internal/resources/stylesheet/menu.setStyleSheet.css')
64 | if css_path and os.path.exists(css_path):
65 | try:
66 | with open(css_path, 'r', encoding='utf-8') as f:
67 | tray_menu.setStyleSheet(f.read())
68 | except Exception as e:
69 | logger.error(f"Error reading menu stylesheet: {str(e)}")
70 | self.tray_icon.setContextMenu(tray_menu)
71 | self.tray_icon.activated.connect(self._handle_tray_activation)
72 | self.tray_icon.show()
73 |
74 | def _connect_signals(self):
75 | self.btnMinimize.clicked.connect(self.showMinimized)
76 | self.btnMaximize.clicked.connect(self._toggle_maximize)
77 | self.btnClose.clicked.connect(self._hide_to_tray)
78 | self.btnGithub.clicked.connect(self._open_github)
79 | self.btnBadge.clicked.connect(self._open_microsoft)
80 |
81 | def mousePressEvent(self, event):
82 | if (event.button() == Qt.MouseButton.LeftButton and
83 | self.rect().contains(event.pos()) and
84 | event.pos().y() < 60):
85 | self._drag_position = event.globalPosition().toPoint() - self.frameGeometry().topLeft()
86 | event.accept()
87 |
88 | def mouseMoveEvent(self, event):
89 | if (event.buttons() == Qt.MouseButton.LeftButton and
90 | not self._drag_position.isNull()):
91 | if self.isMaximized():
92 | self.showNormal()
93 | self.btnMaximize.setIcon(QtGui.QIcon(get_resource_path('_internal/resources/img/窗口控制/最大化.svg')))
94 | screen = QtWidgets.QApplication.primaryScreen().availableGeometry()
95 | window_rect = self.frameGeometry()
96 | center_point = screen.center()
97 | window_rect.moveCenter(center_point)
98 | self.move(window_rect.topLeft())
99 | self._drag_position = event.globalPosition().toPoint() - self.frameGeometry().topLeft()
100 | self.move(event.globalPosition().toPoint() - self._drag_position)
101 | event.accept()
102 |
103 | def mouseReleaseEvent(self, event):
104 | if event.button() == Qt.MouseButton.LeftButton:
105 | self._drag_position = QPoint()
106 |
107 | def _hide_to_tray(self):
108 | self.hide()
109 | self.tray_icon.show()
110 | self.tray_icon.showMessage(
111 | "LeafSort",
112 | "应用在后台继续运行",
113 | QtWidgets.QSystemTrayIcon.MessageIcon.Information,
114 | 3000
115 | )
116 |
117 | def _show_window(self):
118 | self.show()
119 | self.raise_()
120 |
121 | def _exit_app(self):
122 | self._exit_requested = True
123 | QtWidgets.QApplication.quit()
124 |
125 | def _handle_tray_activation(self, reason):
126 | if reason == QtWidgets.QSystemTrayIcon.ActivationReason.Trigger:
127 | if self.isHidden():
128 | self._show_window()
129 | else:
130 | self._hide_to_tray()
131 |
132 | def _toggle_maximize(self):
133 | if self.isMaximized():
134 | self.showNormal()
135 | self.btnMaximize.setIcon(QtGui.QIcon(get_resource_path('_internal/resources/img/窗口控制/最大化.svg')))
136 | else:
137 | self.showMaximized()
138 | self.btnMaximize.setIcon(QtGui.QIcon(get_resource_path('_internal/resources/img/窗口控制/还原.svg')))
139 |
140 | def changeEvent(self, event):
141 | if event.type() == QtCore.QEvent.Type.WindowStateChange:
142 | pass
143 | super().changeEvent(event)
144 |
145 | def closeEvent(self, event):
146 | if self._exit_requested:
147 | event.accept()
148 | else:
149 | event.ignore()
150 | self._hide_to_tray()
151 |
152 | def _open_github(self):
153 | url = QtCore.QUrl("https://gitee.com/Yangshengzhou/leaf-sort")
154 | if not QtGui.QDesktopServices.openUrl(url):
155 | QtWidgets.QMessageBox.information(self, "信息",
156 | "无法打开GitHub页面,请手动访问")
157 |
158 | def _open_microsoft(self):
159 | url = QtCore.QUrl("https://apps.microsoft.com/detail/9p3mkv4xslj8?hl=zh&gl=CN&ocid=pdpshare")
160 | if not QtGui.QDesktopServices.openUrl(url):
161 | QtWidgets.QMessageBox.information(self, "信息",
162 | "无法打开Microsoft页面,请手动访问")
163 |
--------------------------------------------------------------------------------
/UI_UpdateDialog.py:
--------------------------------------------------------------------------------
1 | from PyQt6 import QtCore, QtGui, QtWidgets
2 |
3 |
4 | class Ui_UpdateDialog(object):
5 | def setupUi(self, UpdateDialog):
6 | UpdateDialog.setObjectName("UpdateDialog")
7 | UpdateDialog.resize(512, 320)
8 | self.layoutMain = QtWidgets.QVBoxLayout(UpdateDialog)
9 | self.layoutMain.setContentsMargins(0, 0, 0, 0)
10 | self.layoutMain.setSpacing(0)
11 | self.layoutMain.setObjectName("layoutMain")
12 | self.frameDialog = QtWidgets.QFrame(parent=UpdateDialog)
13 | self.frameDialog.setMinimumSize(QtCore.QSize(512, 320))
14 | self.frameDialog.setStyleSheet("QFrame#frameDialog {\n"
15 | " background-color: rgb(245, 249, 254);\n"
16 | " border-radius: 25px;\n"
17 | "}")
18 | self.frameDialog.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
19 | self.frameDialog.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
20 | self.frameDialog.setObjectName("frameDialog")
21 | self.layoutDialog = QtWidgets.QHBoxLayout(self.frameDialog)
22 | self.layoutDialog.setContentsMargins(0, 0, 0, 0)
23 | self.layoutDialog.setSpacing(12)
24 | self.layoutDialog.setObjectName("layoutDialog")
25 | self.layoutContent = QtWidgets.QVBoxLayout()
26 | self.layoutContent.setContentsMargins(9, 9, 9, 9)
27 | self.layoutContent.setSpacing(0)
28 | self.layoutContent.setObjectName("layoutContent")
29 | self.widgetHeader = QtWidgets.QWidget(parent=self.frameDialog)
30 | self.widgetHeader.setMinimumSize(QtCore.QSize(0, 0))
31 | self.widgetHeader.setMaximumSize(QtCore.QSize(16777215, 16777215))
32 | self.widgetHeader.setStyleSheet("QWidget#widgetHeader {\n"
33 | " background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1,\n"
34 | " stop:0 rgb(105, 27, 253), \n"
35 | " stop:1 rgb(200, 160, 240));\n"
36 | " border-radius: 16px;\n"
37 | " color: rgb(255, 255, 255);\n"
38 | "}")
39 | self.widgetHeader.setObjectName("widgetHeader")
40 | self.layoutHeader = QtWidgets.QHBoxLayout(self.widgetHeader)
41 | self.layoutHeader.setContentsMargins(18, 18, 18, 18)
42 | self.layoutHeader.setSpacing(18)
43 | self.layoutHeader.setObjectName("layoutHeader")
44 | self.layoutTitleContent = QtWidgets.QHBoxLayout()
45 | self.layoutTitleContent.setObjectName("layoutTitleContent")
46 | self.layoutDialogElements = QtWidgets.QVBoxLayout()
47 | self.layoutDialogElements.setSpacing(0)
48 | self.layoutDialogElements.setObjectName("layoutDialogElements")
49 | self.lblTitle = QtWidgets.QLabel(parent=self.widgetHeader)
50 | font = QtGui.QFont()
51 | font.setPointSize(22)
52 | font.setBold(True)
53 | self.lblTitle.setFont(font)
54 | self.lblTitle.setStyleSheet("QLabel#lblTitle {\n"
55 | " color: rgba(222, 255, 255, 255);\n"
56 | " qproperty-alignment: \'AlignCenter\';\n"
57 | " margin-bottom: 12px;\n"
58 | "}")
59 | self.lblTitle.setObjectName("lblTitle")
60 | self.layoutDialogElements.addWidget(self.lblTitle)
61 | self.lblContent = QtWidgets.QLabel(parent=self.widgetHeader)
62 | font = QtGui.QFont()
63 | font.setPointSize(12)
64 | font.setBold(False)
65 | self.lblContent.setFont(font)
66 | self.lblContent.setStyleSheet("QLabel#lblContent {\n"
67 | " color: rgb(250, 250, 250);\n"
68 | "}")
69 | self.lblContent.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeading|QtCore.Qt.AlignmentFlag.AlignLeft|QtCore.Qt.AlignmentFlag.AlignTop)
70 | self.lblContent.setObjectName("lblContent")
71 | self.layoutDialogElements.addWidget(self.lblContent)
72 | self.layoutButtons = QtWidgets.QHBoxLayout()
73 | self.layoutButtons.setContentsMargins(-1, -1, 6, -1)
74 | self.layoutButtons.setSpacing(12)
75 | self.layoutButtons.setObjectName("layoutButtons")
76 | spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
77 | self.layoutButtons.addItem(spacerItem)
78 | self.btnCancel = QtWidgets.QPushButton(parent=self.widgetHeader)
79 | self.btnCancel.setMinimumSize(QtCore.QSize(88, 30))
80 | self.btnCancel.setMaximumSize(QtCore.QSize(88, 30))
81 | self.btnCancel.setStyleSheet("QPushButton#btnCancel {\n"
82 | " background-color: rgba(250, 250, 250, 100);\n"
83 | " color: white;\n"
84 | " border: none;\n"
85 | " border-radius: 8px;\n"
86 | " font-family: \'Microsoft YaHei\';\n"
87 | " font-size: 12px;\n"
88 | "}\n"
89 | "\n"
90 | "QPushButton#btnCancel:hover {\n"
91 | " background-color: rgba(250, 250, 250, 130);\n"
92 | "}\n"
93 | "\n"
94 | "QPushButton#btnCancel:pressed {\n"
95 | " background-color: rgba(250, 250, 250, 180);\n"
96 | "}")
97 | self.btnCancel.setObjectName("btnCancel")
98 | self.layoutButtons.addWidget(self.btnCancel)
99 | self.btnDownload = QtWidgets.QPushButton(parent=self.widgetHeader)
100 | self.btnDownload.setMinimumSize(QtCore.QSize(88, 30))
101 | self.btnDownload.setMaximumSize(QtCore.QSize(88, 30))
102 | self.btnDownload.setStyleSheet("QPushButton#btnDownload {\n"
103 | " background-color: rgba(105, 27, 253, 180);\n"
104 | " color: white;\n"
105 | " border: none;\n"
106 | " border-radius: 8px;\n"
107 | " font-family: \'Microsoft YaHei\';\n"
108 | " font-size: 12px;\n"
109 | "}\n"
110 | "\n"
111 | "QPushButton#btnDownload:hover {\n"
112 | " background-color: rgba(105, 27, 253, 120);\n"
113 | "}\n"
114 | "\n"
115 | "QPushButton#btnDownload:pressed {\n"
116 | " background-color: rgba(105, 27, 253, 250);\n"
117 | "}")
118 | self.btnDownload.setObjectName("btnDownload")
119 | self.layoutButtons.addWidget(self.btnDownload)
120 | self.layoutDialogElements.addLayout(self.layoutButtons)
121 | self.layoutDialogElements.setStretch(0, 1)
122 | self.layoutDialogElements.setStretch(1, 3)
123 | self.layoutDialogElements.setStretch(2, 1)
124 | self.layoutTitleContent.addLayout(self.layoutDialogElements)
125 | self.layoutHeader.addLayout(self.layoutTitleContent)
126 | self.layoutHeader.setStretch(0, 5)
127 | self.layoutContent.addWidget(self.widgetHeader)
128 | self.layoutContent.setStretch(0, 9)
129 | self.layoutDialog.addLayout(self.layoutContent)
130 | self.layoutDialog.setStretch(0, 4)
131 | self.layoutMain.addWidget(self.frameDialog)
132 |
133 | self.retranslateUi(UpdateDialog)
134 | QtCore.QMetaObject.connectSlotsByName(UpdateDialog)
135 |
136 | def retranslateUi(self, UpdateDialog):
137 | _translate = QtCore.QCoreApplication.translate
138 | UpdateDialog.setWindowTitle(_translate("UpdateDialog", "Dialog"))
139 | self.lblTitle.setText(_translate("UpdateDialog", "Dialog Title"))
140 | self.lblContent.setText(_translate("UpdateDialog", "Dialog content"))
141 | self.btnCancel.setText(_translate("UpdateDialog", "暂不更新"))
142 | self.btnDownload.setText(_translate("UpdateDialog", "立即下载"))
143 |
--------------------------------------------------------------------------------
/resources/img/窗口控制/microsoft.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/json/lens_model.json:
--------------------------------------------------------------------------------
1 | {
2 | "Canon": ["RF 24-70mm f/2.8L IS USM", "RF 70-200mm f/2.8L IS USM", "RF 15-35mm f/2.8L IS USM", "RF 50mm f/1.2L USM", "RF 85mm f/1.2L USM", "EF 24-70mm f/2.8L II USM", "EF 70-200mm f/2.8L IS III USM", "EF 16-35mm f/2.8L III USM", "EF-M 15-45mm f/3.5-6.3 IS STM", "EF-M 18-55mm f/3.5-5.6 IS STM", "EF-M 11-22mm f/4-5.6 IS STM", "EF-M 22mm f/2 STM", "EF-M 32mm f/1.4 STM", "EF-M 55-200mm f/4.5-6.3 IS STM", "EF-M 18-150mm f/3.5-6.3 IS STM", "RF 24-105mm f/4-7.1 IS STM", "RF 24-240mm f/4-6.3 IS USM", "RF 35mm f/1.8 Macro IS STM", "RF 85mm f/2 Macro IS STM", "RF 50mm f/1.8 STM", "RF 16mm f/2.8 STM", "RF 100-400mm f/5.6-8 IS USM", "RF 100-500mm f/4.5-7.1L IS USM"],
3 | "Nikon": ["NIKKOR Z 24-70mm f/2.8 S", "NIKKOR Z 70-200mm f/2.8 VR S", "NIKKOR Z 14-24mm f/2.8 S", "NIKKOR Z 50mm f/1.8 S", "NIKKOR Z 85mm f/1.8 S", "AF-S NIKKOR 24-70mm f/2.8E ED VR", "AF-S NIKKOR 70-200mm f/2.8E FL ED VR", "AF-S NIKKOR 14-24mm f/2.8G ED", "NIKKOR Z 24-50mm f/4-6.3", "NIKKOR Z 40mm f/2", "NIKKOR Z 28mm f/2.8", "NIKKOR Z 26mm f/2.8", "NIKKOR Z 24mm f/1.7", "NIKKOR Z 50mm f/2.8", "NIKKOR Z 85mm f/2.8", "NIKKOR Z 14-30mm f/4 S", "NIKKOR Z 24-200mm f/4-6.3 VR", "NIKKOR Z 100-400mm f/4.5-5.6 VR S", "NIKKOR Z 180-600mm f/5.6-6.3 VR", "NIKKOR Z 200-600mm f/6.3 VR S", "AF-P DX NIKKOR 18-55mm f/3.5-5.6G VR", "AF-P DX NIKKOR 70-300mm f/4.5-5.6E ED VR"],
4 | "Sony": ["FE 24-70mm f/2.8 GM II", "FE 70-200mm f/2.8 GM OSS II", "FE 16-35mm f/2.8 GM", "FE 50mm f/1.2 GM", "FE 85mm f/1.4 GM", "FE 24-105mm f/4 G OSS", "FE 12-24mm f/2.8 GM", "FE 135mm f/1.8 GM", "FE 20mm f/1.8 G", "FE 24mm f/1.4 GM", "FE 35mm f/1.4 GM", "FE 50mm f/1.4 GM", "FE 90mm f/2.8 Macro G OSS", "FE 70-300mm f/4.5-5.6 G OSS", "FE 100-400mm f/4.5-5.6 GM OSS", "FE 200-600mm f/5.6-6.3 G OSS", "E 16-55mm f/2.8 G", "E 18-135mm f/3.5-5.6 OSS", "E 10-20mm f/4 OSS", "E 35mm f/1.8 OSS", "E 50mm f/1.8 OSS", "E 16mm f/2.8", "E 20mm f/2.8", "E 24mm f/1.8", "E 30mm f/3.5 Macro", "E 15-50mm f/2.8-4.5", "E 18-55mm f/3.5-5.6 OSS"],
5 | "Fujifilm": ["XF 16-55mm f/2.8 R LM WR", "XF 50-140mm f/2.8 R LM OIS WR", "XF 10-24mm f/4 R OIS", "XF 23mm f/1.4 R LM WR", "XF 35mm f/1.4 R", "XF 56mm f/1.2 R WR", "XF 80mm f/2.8 R LM OIS WR Macro", "GF 32-64mm f/4 R LM WR", "XF 18-55mm f/2.8-4 R LM OIS", "XF 18-135mm f/3.5-5.6 R LM OIS WR", "XF 16-80mm f/4 R OIS WR", "XF 10-24mm f/4 R OIS WR", "XF 27mm f/2.8", "XF 35mm f/2 R WR", "XF 50mm f/2 R WR", "XF 90mm f/2 R LM WR", "XF 70-300mm f/4-5.6 R LM OIS WR", "XF 150-600mm f/5.6-8 R LM OIS WR", "XC 15-45mm f/3.5-5.6 OIS PZ", "XC 16-50mm f/3.5-5.6 OIS II", "XC 50-230mm f/4.5-6.7 OIS II", "XC 35mm f/2", "GF 45-100mm f/4 R LM OIS WR", "GF 23mm f/4 R LM WR", "GF 63mm f/2.8 R WR", "GF 80mm f/1.7 R WR", "GF 110mm f/2 R LM WR", "GF 250mm f/4 R LM OIS WR"],
6 | "Leica": ["APO-Summicron-SL 50mm f/2 ASPH", "APO-Summicron-SL 35mm f/2 ASPH", "APO-Summicron-SL 75mm f/2 ASPH", "Vario-Elmarit-SL 24-90mm f/2.8-4 ASPH", "Summilux-M 50mm f/1.4 ASPH", "Noctilux-M 50mm f/0.95 ASPH", "Summicron-M 35mm f/2 ASPH", "Elmarit-M 28mm f/2.8 ASPH", "Summilux-M 21mm f/1.4 ASPH", "Summilux-M 24mm f/1.4 ASPH", "Summilux-M 28mm f/1.4 ASPH", "Summicron-M 28mm f/2 ASPH", "Summicron-M 50mm f/2 ASPH", "Elmarit-M 21mm f/2.8 ASPH", "Elmarit-M 24mm f/3.8 ASPH", "Elmarit-M 28mm f/2.8 ASPH", "APO-Elmarit-M 90mm f/2.8 ASPH", "Vario-Elmar-R 21-35mm f/4 ASPH", "Vario-Elmar-R 35-70mm f/4 ASPH", "TL 23mm f/2 ASPH", "TL 35mm f/1.4 ASPH", "TL 50mm f/1.4 ASPH", "TL 90mm f/2.8 ASPH"],
7 | "Panasonic": ["LUMIX S PRO 24-70mm f/2.8", "LUMIX S PRO 70-200mm f/2.8 O.I.S.", "LUMIX S 16-35mm f/4", "LUMIX S 50mm f/1.8", "LUMIX S 85mm f/1.8", "LUMIX G X VARIO 12-35mm f/2.8 II ASPH.", "LUMIX G X VARIO 35-100mm f/2.8 II", "LUMIX G 25mm f/1.7 ASPH.", "LUMIX S 24-105mm f/4 MACRO O.I.S.", "LUMIX S 70-300mm f/4.5-5.6 MACRO O.I.S.", "LUMIX S 100-400mm f/5-6.3 DI O.I.S.", "LUMIX S PRO 16-35mm f/4", "LUMIX S PRO 50mm f/1.4", "LUMIX S 35mm f/1.8", "LUMIX S 20-60mm f/3.5-5.6", "LUMIX S 14-28mm f/4-5.6 MACRO", "LUMIX G X VARIO 12-60mm f/3.5-5.6 POWER O.I.S.", "LUMIX G VARIO 14-140mm f/3.5-5.6 ASPH. MEGA O.I.S.", "LUMIX G 42.5mm f/1.7 ASPH. POWER O.I.S.", "LUMIX G 12-32mm f/3.5-5.6 ASPH. MEGA O.I.S.", "LUMIX G 14mm f/2.5 ASPH.", "LUMIX G 20mm f/1.7 ASPH.", "LUMIX G 15mm f/1.7 ASPH.", "LUMIX G 25mm f/1.4 ASPH.", "LUMIX G 30mm f/2.8 ASPH. MEGA O.I.S.", "LUMIX G 42.5mm f/2.8 ASPH. POWER O.I.S.", "LUMIX G 56mm f/1.4 ASPH.", "LUMIX G 100-300mm f/4-5.6 II POWER O.I.S."],
8 | "Olympus": ["M.Zuiko Digital ED 12-40mm f/2.8 PRO", "M.Zuiko Digital ED 40-150mm f/2.8 PRO", "M.Zuiko Digital ED 7-14mm f/2.8 PRO", "M.Zuiko Digital ED 25mm f/1.2 PRO", "M.Zuiko Digital ED 45mm f/1.2 PRO", "M.Zuiko Digital ED 12-100mm f/4.0 IS PRO", "M.Zuiko Digital ED 17mm f/1.2 PRO", "M.Zuiko Digital ED 75mm f/1.8", "M.Zuiko Digital ED 14-150mm f/4-5.6 II", "M.Zuiko Digital ED 12-45mm f/4 PRO", "M.Zuiko Digital ED 8-25mm f/4 PRO", "M.Zuiko Digital ED 40-150mm f/4-5.6 R", "M.Zuiko Digital ED 12-200mm f/3.5-6.3", "M.Zuiko Digital 14-42mm f/3.5-5.6 II R", "M.Zuiko Digital 25mm f/1.8", "M.Zuiko Digital 45mm f/1.8", "M.Zuiko Digital 17mm f/2.8", "M.Zuiko Digital 60mm f/2.8 Macro", "M.Zuiko Digital 75-300mm f/4.8-6.7 II", "M.Zuiko Digital ED 60mm f/2.8 Macro", "M.Zuiko Digital ED 30mm f/3.5 Macro", "M.Zuiko Digital ED 12mm f/2", "M.Zuiko Digital ED 17mm f/1.2 PRO", "M.Zuiko Digital ED 90mm f/3.5 Macro IS PRO"],
9 | "Pentax": ["HD PENTAX-D FA 24-70mm f/2.8ED SDM WR", "HD PENTAX-D FA 70-200mm f/2.8ED DC AW", "HD PENTAX-DA 16-85mm f/3.5-5.6ED DC WR", "HD PENTAX-D FA 50mm f/1.4 SDM AW", "HD PENTAX-D FA 85mm f/1.4ED SDM AW", "HD PENTAX-DA 35mm f/2.8 Macro Limited", "HD PENTAX-DA 21mm f/3.2 AL Limited", "HD PENTAX-DA 70mm f/2.4 Limited", "HD PENTAX-D FA 28-105mm f/3.5-5.6 ED DC WR", "HD PENTAX-D FA 15-30mm f/2.8 ED SDM WR", "HD PENTAX-D FA 24-70mm f/2.8 ED SDM WR", "HD PENTAX-DA 18-135mm f/3.5-5.6 ED AL [IF] DC WR", "HD PENTAX-DA 18-55mm f/3.5-5.6 AL WR", "HD PENTAX-DA 50-200mm f/4-5.6 ED WR", "HD PENTAX-DA 55-300mm f/4-5.8 ED WR", "HD PENTAX-DA 10-17mm f/3.5-4.5 ED [IF] Fish-Eye", "HD PENTAX-DA 12-24mm f/4 ED AL [IF]", "HD PENTAX-DA 14mm f/2.8 ED [IF]"],
10 | "Sigma": ["Art 24-70mm f/2.8 DG DN", "Art 70-200mm f/2.8 DG DN OS", "Art 14-24mm f/2.8 DG DN", "Art 35mm f/1.4 DG HSM", "Art 50mm f/1.4 DG HSM", "Art 85mm f/1.4 DG HSM", "Contemporary 16mm f/1.4 DC DN", "Contemporary 30mm f/1.4 DC DN", "Art 105mm f/1.4 DG HSM", "Art 135mm f/1.8 DG HSM", "Art 20mm f/1.4 DG DN", "Art 24mm f/1.4 DG DN", "Art 28mm f/1.4 DG DN", "Art 40mm f/1.4 DG DN", "Art 56mm f/1.4 DG DN", "Art 65mm f/2 DG DN", "Art 90mm f/2.8 DG DN Macro", "Contemporary 45mm f/2.8 DG DN", "Contemporary 56mm f/1.4 DC DN", "Contemporary 23mm f/1.4 DC DN", "Contemporary 35mm f/1.4 DC DN", "Contemporary 50mm f/1.4 DC DN", "Contemporary 18-50mm f/2.8 DC DN", "Contemporary 150-600mm f/5-6.3 DG OS HSM", "Sports 150-600mm f/5-6.3 DG OS HSM", "Sports 60-600mm f/4.5-6.3 DG OS HSM", "Sports 100-400mm f/5-6.3 DG DN OS"],
11 | "Tamron": ["28-75mm f/2.8 Di III VXD G2", "70-180mm f/2.8 Di III VXD", "17-28mm f/2.8 Di III RXD", "35-150mm f/2-2.8 Di III VXD", "150-500mm f/5-6.7 Di III VC VXD", "20-40mm f/2.8 Di III VXD", "11-20mm f/2.8 Di III-A RXD", "18-300mm f/3.5-6.3 Di III-A VC VXD", "35-150mm f/2.8-4 Di VC OSD", "17-35mm f/2.8-4 Di OSD", "24-70mm f/2.8 Di VC USD G2", "70-200mm f/2.8 Di VC USD G2", "150-600mm f/5-6.3 Di VC USD G2", "16-300mm f/3.5-6.3 Di II VC PZD Macro", "18-200mm f/3.5-6.3 Di II VC", "18-400mm f/3.5-6.3 Di II VC HLD", "10-24mm f/3.5-4.5 Di II VC HLD", "SP 35mm f/1.4 Di USD", "SP 45mm f/1.8 Di VC USD", "SP 90mm f/2.8 Di VC USD Macro"],
12 | "Tokina": ["ATX-i 11-16mm f/2.8 CF", "ATX-i 16-28mm f/2.8", "ATX-i 24-70mm f/2.8", "ATX-i 100mm f/2.8 FF Macro", "ATX-i 50mm f/1.4", "opera 16-28mm f/2.8 FF", "opera 50mm f/1.4 FF", "ATX-i 35mm f/2.8 Macro", "ATX-i 11-20mm f/2.8 PRO", "ATX-i 14-20mm f/2", "ATX-i 20-35mm f/2.8", "ATX-i 28-70mm f/2.8", "ATX-i 35-150mm f/2.8-4", "ATX-i 70-210mm f/4", "ATX-i 100-400mm f/4.5-6.3", "FIRIN 20mm f/2 FE", "FIRIN 100mm f/2.8 FE Macro", "Vista 18-200mm f/3.5-6.3", "Vista 16-28mm f/2.8"]
13 | }
--------------------------------------------------------------------------------
/UI_UpdateDialog.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | UpdateDialog
4 |
5 |
6 |
7 | 0
8 | 0
9 | 512
10 | 320
11 |
12 |
13 |
14 | Dialog
15 |
16 |
17 |
18 | 0
19 |
20 |
21 | 0
22 |
23 |
24 | 0
25 |
26 |
27 | 0
28 |
29 |
30 | 0
31 |
32 | -
33 |
34 |
35 |
36 | 512
37 | 320
38 |
39 |
40 |
41 | QFrame#frameDialog {
42 | background-color: rgb(245, 249, 254);
43 | border-radius: 25px;
44 | }
45 |
46 |
47 | QFrame::StyledPanel
48 |
49 |
50 | QFrame::Raised
51 |
52 |
53 |
54 | 12
55 |
56 |
57 | 0
58 |
59 |
60 | 0
61 |
62 |
63 | 0
64 |
65 |
66 | 0
67 |
68 |
-
69 |
70 |
71 | 0
72 |
73 |
74 | 9
75 |
76 |
77 | 9
78 |
79 |
80 | 9
81 |
82 |
83 | 9
84 |
85 |
-
86 |
87 |
88 |
89 | 0
90 | 0
91 |
92 |
93 |
94 |
95 | 16777215
96 | 16777215
97 |
98 |
99 |
100 | QWidget#widgetHeader {
101 | background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1,
102 | stop:0 rgb(105, 27, 253),
103 | stop:1 rgb(200, 160, 240));
104 | border-radius: 16px;
105 | color: rgb(255, 255, 255);
106 | }
107 |
108 |
109 |
110 | 18
111 |
112 |
113 | 18
114 |
115 |
116 | 18
117 |
118 |
119 | 18
120 |
121 |
122 | 18
123 |
124 |
-
125 |
126 |
-
127 |
128 |
129 | 0
130 |
131 |
-
132 |
133 |
134 |
135 | 22
136 | true
137 |
138 |
139 |
140 | QLabel#lblTitle {
141 | color: rgba(222, 255, 255, 255);
142 | qproperty-alignment: 'AlignCenter';
143 | margin-bottom: 12px;
144 | }
145 |
146 |
147 | Dialog Title
148 |
149 |
150 |
151 | -
152 |
153 |
154 |
155 | 12
156 | false
157 |
158 |
159 |
160 | QLabel#lblContent {
161 | color: rgb(250, 250, 250);
162 | }
163 |
164 |
165 | Dialog content
166 |
167 |
168 | Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop
169 |
170 |
171 |
172 | -
173 |
174 |
175 | 12
176 |
177 |
178 | 6
179 |
180 |
-
181 |
182 |
183 | Qt::Horizontal
184 |
185 |
186 |
187 | 40
188 | 20
189 |
190 |
191 |
192 |
193 | -
194 |
195 |
196 |
197 | 88
198 | 30
199 |
200 |
201 |
202 |
203 | 88
204 | 30
205 |
206 |
207 |
208 | QPushButton#btnCancel {
209 | background-color: rgba(250, 250, 250, 100);
210 | color: white;
211 | border: none;
212 | border-radius: 8px;
213 | font-family: 'Microsoft YaHei';
214 | font-size: 12px;
215 | }
216 |
217 | QPushButton#btnCancel:hover {
218 | background-color: rgba(250, 250, 250, 130);
219 | }
220 |
221 | QPushButton#btnCancel:pressed {
222 | background-color: rgba(250, 250, 250, 180);
223 | }
224 |
225 |
226 | 暂不更新
227 |
228 |
229 |
230 | -
231 |
232 |
233 |
234 | 88
235 | 30
236 |
237 |
238 |
239 |
240 | 88
241 | 30
242 |
243 |
244 |
245 | QPushButton#btnDownload {
246 | background-color: rgba(105, 27, 253, 180);
247 | color: white;
248 | border: none;
249 | border-radius: 8px;
250 | font-family: 'Microsoft YaHei';
251 | font-size: 12px;
252 | }
253 |
254 | QPushButton#btnDownload:hover {
255 | background-color: rgba(105, 27, 253, 120);
256 | }
257 |
258 | QPushButton#btnDownload:pressed {
259 | background-color: rgba(105, 27, 253, 250);
260 | }
261 |
262 |
263 | 立即下载
264 |
265 |
266 |
267 |
268 |
269 |
270 |
271 |
272 |
273 |
274 |
275 |
276 |
277 |
278 |
279 |
280 |
281 |
282 |
283 |
284 |
285 |
286 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
20 |
21 | ## 项目简介
22 |
23 | LeafSort是一款功能强大的图片管理工具,专注于高效管理、智能整理和批量处理各类图片文件。
24 |
25 | ### 核心功能
26 |
27 | - **高效图片管理**:支持多种图片格式,快速浏览和管理大量图片
28 | - **智能整理功能**:按时间、地点、设备等多维度自动分类整理图片
29 | - **文件去重**:基于感知哈希算法智能识别相似图片
30 | - **EXIF编辑**:查看和修改图片元数据信息
31 |
32 | - **批量处理**:支持批量重命名操作
33 |
34 | ## 技术架构
35 |
36 | ### 技术栈
37 |
38 | - **开发语言**: Python 3.11+
39 | - **GUI框架**: PyQt6 6.5.0+
40 | - **图像处理**: Pillow 11.3.0+
41 |
42 | - **元数据处理**: piexif, exiftool
43 | - **其他依赖**: numpy, scikit-image, opencv-python
44 |
45 | ### 架构设计
46 |
47 | LeafSort采用三层架构设计,确保代码的可维护性和扩展性:
48 |
49 | 1. **用户界面层**:基于PyQt6构建的现代化GUI界面
50 | 2. **业务逻辑层**:处理核心业务逻辑和功能实现
51 | 3. **数据处理层**:负责文件操作、元数据处理和数据库交互
52 |
53 | ## 项目结构
54 |
55 | ```
56 | LeafSort/ # 项目根目录
57 | ├── App.py # 应用程序入口
58 | ├── main_window.py # 主窗口实现
59 | ├── Ui_MainWindow.py # UI界面文件
60 | ├── Ui_MainWindow.ui # Qt设计器界面文件
61 | ├── add_folder.py # 文件夹管理功能
62 | ├── smart_arrange.py # 智能整理服务
63 | ├── smart_arrange_thread.py # 智能整理线程
64 | ├── write_exif.py # EXIF编辑功能
65 | ├── write_exif_thread.py # EXIF编辑线程
66 | ├── file_deduplication.py # 文件去重功能
67 | ├── file_deduplication_thread.py # 文件去重线程
68 | ├── common.py # 通用函数
69 | ├── config_manager.py # 配置管理器
70 | ├── update_dialog.py # 更新对话框
71 | ├── UI_UpdateDialog.py # 更新对话框UI
72 | ├── UI_UpdateDialog.ui # Qt设计器界面文件
73 | ├── License\ # 许可证相关文件
74 | ├── resources/ # 资源文件
75 | │ ├── cv2_date/ # OpenCV相关文件
76 | │ ├── exiftool/ # EXIF工具
77 | │ ├── img/ # 图片资源
78 | │ ├── json/ # JSON数据文件
79 | │ └── stylesheet/ # 样式表文件
80 | ├── requirements.txt # 依赖列表
81 | ├── README.md # 项目说明(中文)
82 | └── README_EN.md # 项目说明(英文)
83 | ```
84 |
85 | ## 安装部署
86 |
87 | ### 环境要求
88 |
89 | - Python 3.11 或更高版本
90 | - Windows 10/11, macOS 12+, Linux (Ubuntu 20.04+)
91 |
92 |
93 | ### 源码安装
94 |
95 | 1. 克隆仓库
96 |
97 | ```bash
98 | git clone https://github.com/YangShengzhou03/LeafSort.git
99 | cd LeafSort
100 | ```
101 |
102 | 2. 安装依赖
103 | ```bash
104 | pip install -r requirements.txt
105 | ```
106 |
107 | 3. 运行应用
108 | ```bash
109 | python App.py
110 | ```
111 |
112 | ### 预编译版本
113 |
114 | 可在[GitHub Releases](https://github.com/YangShengzhou03/LeafSort/releases)页面下载对应平台的预编译可执行文件。
115 |
116 | ### 打包为可执行文件
117 |
118 | ```bash
119 | # Windows
120 | pyinstaller -w -F --icon=resources/img/icon.ico App.py
121 | ```
122 |
123 | ## 使用指南
124 |
125 | ### 基本操作
126 |
127 | 1. **导入图片**:通过"文件"菜单或拖拽方式导入图片
128 | 2. **浏览图片**:使用鼠标滚轮、方向键或缩略图列表浏览
129 | 3. **查看详情**:点击"详情"按钮查看图片元数据
130 | 4. **批量处理**:选择多张图片后使用右键菜单进行批量操作
131 |
132 | ### 智能整理
133 |
134 | 智能整理功能支持以下分类方式和文件格式:
135 |
136 | #### 支持的文件格式
137 |
138 | - **图片格式**:`.jpg`, `.jpeg`, `.png`, `.bmp`, `.gif`, `.webp`, `.tiff`, `.heic`, `.heif`, `.svg`
139 | - **视频格式**:`.mp4`, `.avi`, `.mov`, `.wmv`, `.flv`, `.mkv`, `.webm`, `.m4v`, `.3gp`
140 | - **音频格式**:`.mp3`, `.wav`, `.flac`, `.aac`, `.ogg`, `.wma`, `.m4a`
141 |
142 | #### 分类方式及读取信息
143 |
144 | **按时间分类**:
145 |
146 | - 读取EXIF的`DateTimeOriginal`字段
147 | - 支持年份/月份/日期层级结构
148 | - 如:`2024/01/15_文件名.jpg`
149 |
150 | **按地点分类**:
151 |
152 | - 基于GPS坐标进行地理编码
153 | - 支持省份/城市自动识别
154 | - 如:`北京市/朝阳区_文件名.jpg`
155 |
156 | **按设备分类**:
157 |
158 | - **拍摄设备**:读取EXIF的`Make`字段(相机品牌、手机品牌)
159 | - **相机型号**:读取EXIF的`Model`字段(具体设备型号)
160 | - 支持主流相机品牌和手机品牌识别
161 | - 如:`Apple/iPhone_15_Pro_文件名.jpg`
162 |
163 | **按文件类型分类**:
164 |
165 | - 直接使用文件扩展名进行分类
166 | - 如:`JPEG/PNG/HEIC/文件名.jpg`
167 |
168 | **多级分类组合**:
169 |
170 | - 支持最多4级分类组合
171 | - 可自定义分隔符(-、_、空格、无等)
172 | - 如:`2024/Apple/iPhone_15_Pro/JPEG/文件名.jpg`
173 |
174 | #### 操作步骤
175 |
176 | 1. 选择需要整理的图片或文件夹
177 | 2. 点击"智能整理"按钮
178 | 3. 选择目标文件夹路径
179 | 4. 配置分类规则(支持多级组合)
180 | 5. 点击"开始整理"执行操作
181 |
182 | ### 文件去重
183 |
184 | #### 查重功能详情
185 |
186 | **支持的格式**:
187 |
188 | - **主要支持**:所有图片格式(基于文件内容检测)
189 | - **算法原理**:感知哈希算法(Perceptual Hash)
190 | - **智能识别**:支持不同尺寸但内容相似的图片识别
191 | - **格式无关**:忽略文件格式差异(只要是图片即可)
192 |
193 | **查重原理**:
194 |
195 | - 将图片转换为哈希值进行比较
196 | - 检测图片内容的相似性,而非文件名或大小
197 | - 支持完全相同、轻微编辑、尺寸调整的图片识别
198 |
199 | #### 操作步骤
200 |
201 | 1. 选择需要去重的文件夹或图片集
202 | 2. 点击"查找重复"按钮
203 | 3. 设置相似度阈值(0-100%)
204 | - 90-100%:识别几乎相同的图片
205 | - 70-89%:识别相似但可能有轻微差异的图片
206 | - 50-69%:识别有部分相似性的图片
207 | 4. 查看重复结果,选择保留或删除
208 | 5. 支持随机选择、批量操作和手动筛选
209 |
210 | #### 重复文件处理
211 |
212 | - **自动分组**:相似文件自动分组显示
213 | - **预览对比**:并排显示重复图片进行对比
214 | - **智能建议**:根据文件大小、创建时间等提供保留建议
215 | - **安全删除**:支持移动到回收站或永久删除
216 |
217 | ### EXIF编辑
218 |
219 | #### 支持的文件格式和可写入属性
220 |
221 | **JPEG/PNG/WebP格式**:
222 |
223 | - **标题** (Title/ImageDescription)
224 | - **作者** (Artist/Creator)
225 | - **评级** (Rating) - 1-5星评分系统
226 | - **拍摄时间** (DateTimeOriginal) - 支持自定义时间源
227 | - **拍摄设备信息** (Make/Model) - 支持主流相机和手机品牌
228 | - **镜头信息** (LensModel/LensMake) - 基于设备自动匹配
229 | - **地理位置信息** (GPS相关标签) - 支持经纬度写入
230 | - **版权信息** (Copyright)
231 | - **关键词/标签** (XPKeywords等)
232 |
233 | **HEIC/HEIF格式**:
234 |
235 | - **标题和描述** (部分支持)
236 | - **作者信息** (有限支持)
237 | - **评级** (部分设备支持)
238 | - **拍摄时间**
239 | - **设备信息**
240 | - *注:部分属性支持可能有限,取决于设备兼容性*
241 |
242 | **视频格式(MOV/MP4/AVI/MKV)**:
243 |
244 | - **标题** (Title)
245 | - **作者/创建者** (Author/Creator)
246 | - **评级** (有限支持)
247 | - **拍摄时间**
248 | - **基本设备信息**
249 | - *注:视频元数据支持相对简单,主要用于基本标识*
250 |
251 | **RAW格式(CR2/CR3/NEF/ARW/ORF/DNG/RAF)**:
252 |
253 | - **标题** (Title)
254 | - **作者信息**
255 | - **评级** (受限于格式规范)
256 | - **拍摄时间**
257 | - **部分设备信息**
258 | - *注:RAW格式的元数据写入受到相机厂商格式限制*
259 |
260 | #### 设备信息数据库
261 |
262 | 内置丰富的设备信息数据库,支持:
263 |
264 | - **相机品牌**:Canon、Nikon、Sony、Fujifilm、Leica、Panasonic、Olympus、Pentax、Sigma等
265 | - **手机品牌**:Apple、Xiaomi、Huawei、OPPO、vivo、OnePlus、Samsung、Google等
266 | - **自动镜头匹配**:根据相机型号自动匹配对应的镜头信息
267 |
268 | #### 操作步骤
269 |
270 | 1. 选择需要编辑的图片
271 | 2. 点击"编辑EXIF"按钮
272 | 3. 配置编辑选项:
273 | - **时间设置**:使用文件时间/EXIF时间/自定义时间
274 | - **设备信息**:选择相机品牌和型号(自动匹配镜头信息)
275 | - **评级设置**:1-5星评分
276 | - **基本信息**:标题、作者、版权等
277 | 4. 点击"开始写入EXIF信息"应用修改
278 |
279 | #### 重要提醒
280 |
281 | - **不可逆操作**:EXIF写入一旦完成无法撤销,建议先备份原文件
282 | - **格式兼容**:不同格式的EXIF支持程度不同,兼容性有所差异
283 | - **HEIC支持**:需要额外的解码器支持(pillow-heif库)
284 | - **地理信息**:需要图片本身包含GPS数据才能获取和设置位置信息
285 |
286 |
287 |
288 | ## 高级功能
289 |
290 | ### 性能优化
291 |
292 | - **缓存机制**:缩略图和元数据缓存
293 | - **批量处理**:分批处理大型图片集合
294 | - **并发处理**:使用多线程优化IO密集型任务
295 |
296 | ## 故障排除
297 |
298 | ### 常见问题
299 |
300 | 1. **模块导入错误**
301 | - 确保所有依赖已正确安装:`pip install -r requirements.txt --upgrade`
302 |
303 | 2. **HEIC/HEIF格式支持**
304 | - 安装pillow-heif库:`pip install pillow-heif --upgrade`
305 |
306 |
307 |
308 | 4. **内存占用过高**
309 | - 减少缓存大小或分批处理图片
310 |
311 | ### 错误代码参考
312 |
313 | | 错误代码 | 描述 | 解决方案 |
314 | |----------|------|----------|
315 | | ERR-001 | 文件不存在或无法访问 | 检查文件路径和权限 |
316 | | ERR-002 | 不支持的文件格式 | 安装相应的解码器库 |
317 | | ERR-003 | 内存不足 | 减少处理批量或增加内存 |
318 | | ERR-004 | 磁盘空间不足 | 清理磁盘空间 |
319 | | ERR-005 | 网络连接失败 | 检查网络设置 |
320 | | ERR-006 | 权限被拒绝 | 以管理员权限运行 |
321 | | ERR-007 | 依赖库缺失 | 重新安装依赖库 |
322 | | ERR-008 | 配置文件损坏 | 删除配置文件重新生成 |
323 |
324 | ## 社区支持
325 |
326 | ### 联系方式
327 |
328 | - **作者**: YangShengzhou03
329 | - **GitHub**: [https://github.com/YangShengzhou03](https://github.com/YangShengzhou03)
330 | - **问题反馈**: [GitHub Issues](https://github.com/YangShengzhou03/LeafSort/issues)
331 | - **讨论区**: [GitHub Discussions](https://github.com/YangShengzhou03/LeafSort/discussions)
332 |
333 | ## 许可证
334 |
335 | ```
336 | MIT License
337 |
338 | Copyright (c) 2024 YangShengzhou03
339 |
340 | Permission is hereby granted, free of charge, to any person obtaining a copy
341 | of this software and associated documentation files (the "Software"), to deal
342 | in the Software without restriction, including without limitation the rights
343 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
344 | copies of the Software, and to permit persons to whom the Software is
345 | furnished to do so, subject to the following conditions:
346 |
347 | The above copyright notice and this permission notice shall be included in all
348 | copies or substantial portions of the Software.
349 |
350 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
351 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
352 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
353 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
354 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
355 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
356 | SOFTWARE.
357 | ```
358 |
359 | ## 更新日志
360 |
361 | ### v1.0.0 (2024-XX-XX)
362 |
363 | #### 新增功能
364 |
365 | - 初始版本发布
366 | - 实现基本的图片浏览和管理功能
367 | - 智能整理功能:支持按时间、设备等多维度分类
368 | - 去重功能:基于感知哈希算法检测重复图片
369 | - EXIF编辑功能:查看和修改图片元数据
370 |
371 |
372 | #### 修复问题
373 |
374 | - 修复HEIC/HEIF格式图片无法打开的问题
375 | - 修复大文件处理时的内存泄漏问题
376 | - 修复多线程处理中的竞态条件
377 |
378 | #### 性能优化
379 |
380 | - 优化缩略图生成和缓存机制
381 | - 改进大批量文件处理性能
382 | - 减少内存占用,提高运行稳定性
383 |
384 |
385 |
Built with Python and PyQt6
386 |
--------------------------------------------------------------------------------
/resources/json/camera_lens_mapping.json:
--------------------------------------------------------------------------------
1 | {
2 | "Canon": {
3 | "EOS R6 Mark III": "RF 24-105mm f/4L IS USM",
4 | "EOS R3 Mark II": "RF 24-70mm f/2.8L IS USM",
5 | "EOS R5 Mark II": "RF 24-70mm f/2.8L IS USM",
6 | "EOS R5": "RF 24-70mm f/2.8L IS USM",
7 | "EOS R6 Mark II": "RF 24-105mm f/4L IS USM",
8 | "EOS R50 V": "RF-S 18-45mm f/4.5-6.3 IS STM",
9 | "EOS R8": "RF 24-50mm f/4.5-6.3 IS STM",
10 | "EOS R6": "RF 24-105mm f/4L IS USM",
11 | "EOS R3": "RF 24-70mm f/2.8L IS USM",
12 | "EOS R50 Mark II": "RF-S 18-45mm f/4.5-6.3 IS STM",
13 | "EOS R50": "RF-S 18-45mm f/4.5-6.3 IS STM",
14 | "EOS R10 Mark II": "RF-S 18-45mm f/4.5-6.3 IS STM",
15 | "EOS R10": "RF-S 18-45mm f/4.5-6.3 IS STM",
16 | "EOS RP II": "RF 24-50mm f/4.5-6.3 IS STM",
17 | "EOS 850D": "EF-S 18-55mm f/4-5.6 IS STM",
18 | "EOS 90D": "EF-S 18-135mm f/3.5-5.6 IS USM"
19 | },
20 | "Nikon": {
21 | "ZR": "NIKKOR Z 24-70mm f/4 S",
22 | "Zf": "NIKKOR Z 28-75mm f/2.8",
23 | "Z9 II": "NIKKOR Z 24-70mm f/2.8 S",
24 | "Z8": "NIKKOR Z 24-70mm f/2.8 S",
25 | "Z7 III": "NIKKOR Z 24-70mm f/4 S",
26 | "Z6 III": "NIKKOR Z 24-70mm f/4 S",
27 | "Z7 II": "NIKKOR Z 24-70mm f/2.8 S",
28 | "Z6 II": "NIKKOR Z 24-70mm f/4 S",
29 | "Z50 II": "NIKKOR Z DX 16-50mm f/3.5-6.3 VR",
30 | "Z50": "NIKKOR Z DX 16-50mm f/3.5-6.3 VR",
31 | "Z30": "NIKKOR Z DX 16-50mm f/3.5-6.3 VR",
32 | "Zfc": "NIKKOR Z DX 16-50mm f/3.5-6.3 VR",
33 | "Z5": "NIKKOR Z 24-50mm f/4-6.3",
34 | "Z9": "NIKKOR Z 24-70mm f/2.8 S",
35 | "Z6": "NIKKOR Z 24-70mm f/4 S",
36 | "Z7": "NIKKOR Z 24-70mm f/4 S"
37 | },
38 | "Sony": {
39 | "A1 II": "FE 24-70mm f/2.8 GM II",
40 | "A9 III": "FE 24-70mm f/2.8 GM II",
41 | "A7C III": "FE 28-60mm f/4-5.6",
42 | "A7R V": "FE 24-70mm f/2.8 GM II",
43 | "A7S III": "FE 24-70mm f/2.8 GM II",
44 | "A7 IV": "FE 28-70mm f/3.5-5.6 OSS",
45 | "A7 III": "FE 28-70mm f/3.5-5.6 OSS",
46 | "A7C II": "FE 28-60mm f/4-5.6",
47 | "A7C": "FE 28-60mm f/4-5.6",
48 | "A1": "FE 24-70mm f/2.8 GM II",
49 | "A6700": "E 18-135mm f/3.5-5.6 OSS",
50 | "ZV-E10 Mark II": "E 16-50mm f/3.5-5.6 OSS",
51 | "ZV-E10": "E 16-50mm f/3.5-5.6 OSS",
52 | "FX30": "FE 24-70mm f/2.8 GM II",
53 | "FX3": "FE 24-70mm f/2.8 GM II",
54 | "ZV-1 II": "Fixed 24-70mm f/1.8-2.8"
55 | },
56 | "Fujifilm": {
57 | "GFX100RF": "GF 45-100mm f/4 R LM OIS WR",
58 | "X-Half": "XC 15-45mm f/3.5-5.6 OIS PZ",
59 | "X100VII": "Fixed 23mm f/2.0",
60 | "X-M5": "XC 15-45mm f/3.5-5.6 OIS PZ",
61 | "X-T5 II": "XF 18-55mm f/2.8-4 R LM OIS",
62 | "X-T4": "XF 18-55mm f/2.8-4 R LM OIS",
63 | "X-T40": "XC 15-45mm f/3.5-5.6 OIS PZ",
64 | "X-T30 III": "XC 15-45mm f/3.5-5.6 OIS PZ",
65 | "X-T30 II": "XC 15-45mm f/3.5-5.6 OIS PZ",
66 | "X100VI": "Fixed 23mm f/2.0",
67 | "X100V": "Fixed 23mm f/2.0",
68 | "X-S20": "XC 15-45mm f/3.5-5.6 OIS PZ",
69 | "X-S10": "XC 15-45mm f/3.5-5.6 OIS PZ",
70 | "X-E5": "XC 15-45mm f/3.5-5.6 OIS PZ",
71 | "X-E4": "XC 15-45mm f/3.5-5.6 OIS PZ",
72 | "GFX Eterna": "GF 45-100mm f/4 R LM OIS WR"
73 | },
74 | "Leica": {
75 | "Q3": "Fixed 28mm f/1.7",
76 | "Q3 43": "Fixed 43mm f/1.7",
77 | "Q2": "Fixed 28mm f/1.7",
78 | "M11 Monochrom": "Summicron-M 35mm f/2 ASPH",
79 | "M11": "Summicron-M 35mm f/2 ASPH",
80 | "SL3": "APO-Summicron-SL 35mm f/2 ASPH",
81 | "SL2-S": "APO-Summicron-SL 35mm f/2 ASPH",
82 | "CL2": "TL 18mm f/2.8 ASPH",
83 | "CL": "TL 18mm f/2.8 ASPH",
84 | "M10-R": "Summicron-M 35mm f/2 ASPH",
85 | "Q2 Monochrom": "Fixed 28mm f/1.7",
86 | "M9-P": "Summicron-M 35mm f/2 ASPH",
87 | "SL2": "APO-Summicron-SL 35mm f/2 ASPH",
88 | "TL2": "TL 18-56mm f/3.5-5.6 ASPH",
89 | "X Vario": "Fixed 28-70mm f/3.5-5.6",
90 | "C-Lux": "Fixed 24-70mm f/1.7-2.8"
91 | },
92 | "Panasonic": {
93 | "S1R II": "LUMIX S 24-105mm f/4 MACRO O.I.S.",
94 | "S1H II": "LUMIX S 24-105mm f/4 MACRO O.I.S.",
95 | "GH7": "LUMIX G 12-60mm f/3.5-5.6 POWER O.I.S.",
96 | "GH6": "LUMIX G 12-60mm f/3.5-5.6 POWER O.I.S.",
97 | "GH5 II": "LUMIX G 12-60mm f/3.5-5.6 POWER O.I.S.",
98 | "G9 II": "LUMIX G 12-60mm f/3.5-5.6 POWER O.I.S.",
99 | "G9": "LUMIX G 12-60mm f/3.5-5.6 POWER O.I.S.",
100 | "S5 II": "LUMIX S 20-60mm f/3.5-5.6",
101 | "S5 IIX": "LUMIX S 20-60mm f/3.5-5.6",
102 | "S5": "LUMIX S 20-60mm f/3.5-5.6",
103 | "S1": "LUMIX S 24-105mm f/4 MACRO O.I.S.",
104 | "S1R": "LUMIX S 24-105mm f/4 MACRO O.I.S.",
105 | "G85": "LUMIX G 12-60mm f/3.5-5.6 POWER O.I.S.",
106 | "GX9": "LUMIX G 12-32mm f/3.5-5.6 ASPH. MEGA O.I.S.",
107 | "ZS220": "Fixed 24-360mm f/3.3-6.4",
108 | "LX100 II": "Fixed 24-75mm f/1.7-2.8"
109 | },
110 | "Xiaomi": {
111 | "17 Pro Max": "LEICA Summicron 50mm f/1.4",
112 | "17 Pro": "LEICA Summicron 50mm f/1.4",
113 | "17": "LEICA Summicron 50mm f/1.4",
114 | "REDMI Turbo4 Pro": "REDMI Camera 50mm f/1.8",
115 | "REDMI K80 Ultra": "REDMI Camera 50mm f/1.8",
116 | "Civi6 Pro": "Xiaomi Camera 50mm f/1.8",
117 | "16 Ultra": "LEICA Summicron 50mm f/1.4",
118 | "16 Pro": "LEICA Summicron 50mm f/1.4",
119 | "16": "Xiaomi Camera 50mm f/1.8",
120 | "15 Pro": "LEICA Summicron 50mm f/1.4",
121 | "15": "Xiaomi Camera 50mm f/1.8",
122 | "REDMI Note 13 Pro": "REDMI Camera 50mm f/1.8",
123 | "Civi 5": "Xiaomi Camera 50mm f/1.8",
124 | "14 Ultra": "LEICA Summicron 50mm f/1.4",
125 | "14 Pro": "LEICA Summicron 50mm f/1.4",
126 | "14": "Xiaomi Camera 50mm f/1.8"
127 | },
128 | "Huawei": {
129 | "Mate 80 Pro Max": "LEICA Summilux 28mm f/1.4",
130 | "Mate 80 Pro": "LEICA Summilux 28mm f/1.4",
131 | "Mate 80": "LEICA Summicron 28mm f/2.0",
132 | "Mate 80 RS": "LEICA Summilux 28mm f/1.4",
133 | "Mate X7": "LEICA Summilux 28mm f/1.4",
134 | "Mate 70 Air": "LEICA Summicron 28mm f/2.0",
135 | "nova Flip": "Huawei Camera 28mm f/2.0",
136 | "Pura 70 Ultra": "LEICA Summilux 28mm f/1.4",
137 | "Mate 70 Pro": "LEICA Summilux 28mm f/1.4",
138 | "Mate 70": "LEICA Summicron 28mm f/2.0",
139 | "nova 13": "Huawei Camera 28mm f/2.0",
140 | "nova 13 Pro": "Huawei Camera 28mm f/1.8",
141 | "P60 Pro": "LEICA Summilux 28mm f/1.4",
142 | "P60": "LEICA Summicron 28mm f/2.0",
143 | "Mate X6": "LEICA Summilux 28mm f/1.4",
144 | "Mate 60 Pro": "LEICA Summilux 28mm f/1.4"
145 | },
146 | "Apple": {
147 | "iPhone 17 Pro Max": "Apple Camera 26mm f/1.7",
148 | "iPhone 17 Pro": "Apple Camera 26mm f/1.7",
149 | "iPhone 17": "Apple Camera 26mm f/1.7",
150 | "iPhone Air": "Apple Camera 26mm f/1.8",
151 | "iPhone 16 Pro Max": "Apple Camera 26mm f/1.7",
152 | "iPhone 16 Pro": "Apple Camera 26mm f/1.7",
153 | "iPhone 16 Plus": "Apple Camera 26mm f/1.8",
154 | "iPhone 16": "Apple Camera 26mm f/1.8",
155 | "iPhone SE 3": "Apple Camera 26mm f/1.8",
156 | "iPhone 15 Pro Max": "Apple Camera 26mm f/1.7",
157 | "iPhone 15 Pro": "Apple Camera 26mm f/1.7",
158 | "iPhone 15 Plus": "Apple Camera 26mm f/1.8",
159 | "iPhone 15": "Apple Camera 26mm f/1.8",
160 | "iPhone 14 Pro Max": "Apple Camera 26mm f/1.7",
161 | "iPhone 14 Pro": "Apple Camera 26mm f/1.7",
162 | "iPhone 14": "Apple Camera 26mm f/1.8"
163 | },
164 | "Samsung": {
165 | "Galaxy S25 Ultra": "SAMSUNG S5KHP3 26mm f/1.7",
166 | "Galaxy S25+": "SAMSUNG S5KHP3 26mm f/1.7",
167 | "Galaxy S25": "SAMSUNG S5KHM6 26mm f/1.8",
168 | "Galaxy S25 FE": "SAMSUNG S5KHM6 26mm f/1.8",
169 | "Galaxy Z Fold7": "SAMSUNG S5KHP3 26mm f/1.7",
170 | "Galaxy Z Flip7": "SAMSUNG S5KHM6 26mm f/1.8",
171 | "Galaxy Z Fold6": "SAMSUNG S5KHP3 26mm f/1.7",
172 | "Galaxy Z Flip6": "SAMSUNG S5KHM6 26mm f/1.8",
173 | "Galaxy S24 Ultra": "SAMSUNG S5KHP3 26mm f/1.7",
174 | "Galaxy S24+": "SAMSUNG S5KHP3 26mm f/1.7",
175 | "Galaxy S24": "SAMSUNG S5KHM6 26mm f/1.8",
176 | "Galaxy A55": "SAMSUNG S5KHM3 26mm f/1.8",
177 | "Galaxy A35": "SAMSUNG S5KHM3 26mm f/1.8",
178 | "Galaxy Tab S9 Ultra": "SAMSUNG S5KHM6 26mm f/1.8",
179 | "Galaxy Watch 7": "SAMSUNG S5KLSI 12mm f/2.2",
180 | "Galaxy Buds3": "SAMSUNG S5KSL8 12mm f/2.4"
181 | },
182 | "OPPO": {
183 | "Find X8 Ultra": "OPPO 哈苏 26mm f/1.6",
184 | "Find X8 Pro": "OPPO 哈苏 26mm f/1.6",
185 | "Find X8": "OPPO Camera 26mm f/1.8",
186 | "Find X7 Ultra": "OPPO 哈苏 26mm f/1.6",
187 | "Find X7 Pro": "OPPO 哈苏 26mm f/1.6",
188 | "Find X7": "OPPO Camera 26mm f/1.8",
189 | "Reno13 Pro+": "OPPO Camera 26mm f/1.7",
190 | "Reno13 Pro": "OPPO Camera 26mm f/1.7",
191 | "Reno13": "OPPO Camera 26mm f/1.8",
192 | "Reno12 Pro": "OPPO Camera 26mm f/1.7",
193 | "Reno12": "OPPO Camera 26mm f/1.8",
194 | "A2 Pro": "OPPO Camera 26mm f/1.8",
195 | "K12": "OPPO Camera 26mm f/1.8",
196 | "F25 Pro": "OPPO Camera 26mm f/1.8",
197 | "N3 Flip": "OPPO Camera 26mm f/1.8",
198 | "Reno13 Lite": "OPPO Camera 26mm f/1.8"
199 | },
200 | "vivo": {
201 | "X200 Ultra": "vivo ZEISS 26mm f/1.4",
202 | "X200 Pro": "vivo ZEISS 26mm f/1.4",
203 | "X200 Pro mini": "vivo ZEISS 26mm f/1.4",
204 | "X200": "vivo Camera 26mm f/1.7",
205 | "X100 Ultra": "vivo ZEISS 26mm f/1.4",
206 | "X100 Pro": "vivo ZEISS 26mm f/1.4",
207 | "X100": "vivo Camera 26mm f/1.7",
208 | "S18 Pro": "vivo Camera 26mm f/1.7",
209 | "S18": "vivo Camera 26mm f/1.8",
210 | "S17 Pro": "vivo Camera 26mm f/1.7",
211 | "S17": "vivo Camera 26mm f/1.8",
212 | "iQOO 12 Pro": "vivo Camera 26mm f/1.7",
213 | "iQOO 12": "vivo Camera 26mm f/1.8",
214 | "T2x": "vivo Camera 26mm f/1.8",
215 | "Y78+": "vivo Camera 26mm f/1.8",
216 | "Y100": "vivo Camera 26mm f/1.8"
217 | },
218 | "OnePlus": {
219 | "13": "OnePlus Hasselblad 26mm f/1.7",
220 | "13T": "OnePlus Hasselblad 26mm f/1.7",
221 | "Ace 5": "OnePlus Camera 26mm f/1.8",
222 | "Ace 5 Pro": "OnePlus Camera 26mm f/1.8",
223 | "12R": "OnePlus Camera 26mm f/1.8",
224 | "12": "OnePlus Hasselblad 26mm f/1.7",
225 | "11": "OnePlus Hasselblad 26mm f/1.7",
226 | "11R": "OnePlus Camera 26mm f/1.8",
227 | "10 Pro": "OnePlus Hasselblad 26mm f/1.8",
228 | "10": "OnePlus Camera 26mm f/1.8",
229 | "9 Pro": "OnePlus Hasselblad 26mm f/1.8",
230 | "9": "OnePlus Camera 26mm f/1.8",
231 | "Nord 3": "OnePlus Camera 26mm f/1.8",
232 | "Nord CE 3": "OnePlus Camera 26mm f/1.8",
233 | "Ace 3": "OnePlus Camera 26mm f/1.8",
234 | "Ace 2 Pro": "OnePlus Camera 26mm f/1.8"
235 | }
236 | }
--------------------------------------------------------------------------------
/file_deduplication.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 |
4 | from PyQt6 import QtWidgets, QtCore
5 |
6 | from file_deduplication_thread import FileScanThread, FileDeduplicateThread
7 |
8 | logging.basicConfig(level=logging.INFO)
9 | logger = logging.getLogger(__name__)
10 |
11 | class FileDeduplicationManager(QtWidgets.QWidget):
12 | progress_updated = QtCore.pyqtSignal(int, str)
13 | scan_completed = QtCore.pyqtSignal(list)
14 | error_occurred = QtCore.pyqtSignal(str)
15 |
16 | def __init__(self, parent=None, folder_page=None):
17 | super().__init__(parent)
18 | self.parent = parent
19 | self.folder_page = folder_page
20 | self.scan_thread = None
21 | self.deduplicate_thread = None
22 | self.duplicate_groups = []
23 | self.current_group_index = -1
24 | self.selected_files = set()
25 | self.filters = []
26 |
27 | self._setup_ui()
28 | self._connect_signals()
29 |
30 | def _setup_ui(self):
31 | self.parent.duplicateItemsListWidget.clear()
32 |
33 | self.parent.duplicateFilesTableWidget.setRowCount(0)
34 | self.parent.duplicateFilesTableWidget.setColumnCount(3)
35 | headers = ["选择", "文件名", "文件路径"]
36 | self.parent.duplicateFilesTableWidget.setHorizontalHeaderLabels(headers)
37 |
38 | header = self.parent.duplicateFilesTableWidget.horizontalHeader()
39 |
40 | header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Fixed)
41 | header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Interactive)
42 | header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.Stretch)
43 |
44 | self.parent.duplicateFilesTableWidget.setColumnWidth(0, 65)
45 | self.parent.duplicateFilesTableWidget.setColumnWidth(1, 250)
46 |
47 | self.parent.duplicateFilesTableWidget.setAlternatingRowColors(True)
48 | self.parent.duplicateFilesTableWidget.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows)
49 | self.parent.duplicateFilesTableWidget.setSortingEnabled(True)
50 |
51 | self.parent.contrastProgressBar.setValue(0)
52 |
53 | def _connect_signals(self):
54 | self.parent.btnStartDeduplication.clicked.connect(self.start_scan)
55 | self.parent.btnRandomSelect.clicked.connect(self.random_select)
56 | self.parent.btnMoveToRecycleBin.clicked.connect(self.move_to_recycle_bin)
57 |
58 | self.parent.duplicateItemsListWidget.currentRowChanged.connect(self.on_group_selected)
59 |
60 | self.parent.duplicateFilesTableWidget.cellChanged.connect(self.on_file_selection_changed)
61 |
62 | def start_scan(self):
63 | if not self.folder_page:
64 | QtWidgets.QMessageBox.warning(self.parent, "警告", "系统错误:folder_page未初始化")
65 | return
66 |
67 | folders = self.folder_page.get_all_folders()
68 | if not folders:
69 | QtWidgets.QMessageBox.warning(self.parent, "警告", "请先选择要扫描的文件夹")
70 | return
71 |
72 | self.duplicate_groups = []
73 | self.current_group_index = -1
74 | self.selected_files.clear()
75 | self.parent.duplicateItemsListWidget.clear()
76 | self.parent.duplicateFilesTableWidget.setRowCount(0)
77 | self.parent.contrastProgressBar.setValue(0)
78 |
79 | self.parent.btnStartDeduplication.setEnabled(False)
80 | self.parent.btnStartDeduplication.setText("扫描中...")
81 |
82 | self.scan_thread = FileScanThread(folders, self.filters)
83 | self.scan_thread.progress_updated.connect(self.on_scan_progress)
84 | self.scan_thread.scan_completed.connect(self.on_scan_completed)
85 | self.scan_thread.error_occurred.connect(self.on_scan_error)
86 | self.scan_thread.start()
87 |
88 | if folders:
89 | logger.info(f"开始扫描文件夹: {folders}")
90 |
91 | def on_scan_progress(self, progress, status_text):
92 | self.parent.contrastProgressBar.setValue(progress)
93 |
94 | def on_scan_completed(self, duplicate_groups):
95 | self.duplicate_groups = duplicate_groups
96 |
97 | self.parent.duplicateItemsListWidget.clear()
98 | for i, group in enumerate(duplicate_groups):
99 | item = QtWidgets.QListWidgetItem(f"重复文件组 {i+1} ({len(group)}个文件)")
100 | self.parent.duplicateItemsListWidget.addItem(item)
101 |
102 | self.parent.btnStartDeduplication.setEnabled(True)
103 | self.parent.btnStartDeduplication.setText("开始查重")
104 | self.parent.contrastProgressBar.setValue(100)
105 |
106 | if duplicate_groups:
107 | QtWidgets.QMessageBox.information(self.parent, "扫描完成",
108 | f"找到 {len(duplicate_groups)} 组重复文件,您可以在左侧列表中查看详情")
109 |
110 | self.parent.duplicateItemsListWidget.setCurrentRow(0)
111 | self.on_group_selected(0)
112 | else:
113 | QtWidgets.QMessageBox.information(self.parent, "扫描完成", "未找到重复文件,每一个文件都是独一无二的")
114 |
115 | self.parent.btnStartDeduplication.setEnabled(True)
116 | self.parent.btnStartDeduplication.setText("开始查重")
117 | self.parent.contrastProgressBar.setValue(100)
118 |
119 | def on_scan_error(self, error_message):
120 | self.parent.btnStartDeduplication.setEnabled(True)
121 | self.parent.btnStartDeduplication.setText("开始查重")
122 | QtWidgets.QMessageBox.critical(self.parent, "扫描错误", error_message)
123 | logger.error(f"扫描错误: {error_message}")
124 |
125 | def on_group_selected(self, row):
126 | if row < 0 or row >= len(self.duplicate_groups):
127 | return
128 |
129 | self.current_group_index = row
130 | self._display_group_files(self.duplicate_groups[row])
131 |
132 | def _display_group_files(self, file_group):
133 | self.parent.duplicateFilesTableWidget.setRowCount(0)
134 |
135 | for i, file_path in enumerate(file_group):
136 | self.parent.duplicateFilesTableWidget.insertRow(i)
137 |
138 | checkbox = QtWidgets.QCheckBox()
139 | checkbox.setChecked(file_path in self.selected_files)
140 | checkbox.stateChanged.connect(lambda state, path=file_path: self._on_checkbox_changed(state, path))
141 | checkbox_widget = QtWidgets.QWidget()
142 | layout = QtWidgets.QHBoxLayout(checkbox_widget)
143 | layout.addWidget(checkbox)
144 | layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
145 | layout.setContentsMargins(5, 3, 5, 3)
146 | self.parent.duplicateFilesTableWidget.setCellWidget(i, 0, checkbox_widget)
147 |
148 | file_name = os.path.basename(file_path)
149 | self.parent.duplicateFilesTableWidget.setItem(i, 1, QtWidgets.QTableWidgetItem(file_name))
150 |
151 | path_item = QtWidgets.QTableWidgetItem(file_path)
152 | path_item.setToolTip(file_path)
153 | self.parent.duplicateFilesTableWidget.setItem(i, 2, path_item)
154 |
155 | def _on_checkbox_changed(self, state, file_path):
156 | if state == QtCore.Qt.CheckState.Checked.value:
157 | self.selected_files.add(file_path)
158 | else:
159 | self.selected_files.discard(file_path)
160 |
161 | self._update_selection_status()
162 |
163 | def _update_selection_status(self):
164 | if hasattr(self.parent, 'statusBar'):
165 | status_bar = self.parent.statusBar()
166 | if status_bar:
167 | status_bar.showMessage(f"已选择 {len(self.selected_files)} 个文件待删除")
168 |
169 | def on_file_selection_changed(self, row, column):
170 | if column != 0 or self.current_group_index < 0:
171 | return
172 |
173 | file_group = self.duplicate_groups[self.current_group_index]
174 | if row < len(file_group):
175 | file_path = file_group[row]
176 | checkbox_widget = self.parent.duplicateFilesTableWidget.cellWidget(row, 0)
177 | checkbox = checkbox_widget.findChild(QtWidgets.QCheckBox)
178 |
179 | if checkbox.isChecked():
180 | self.selected_files.add(file_path)
181 | else:
182 | self.selected_files.discard(file_path)
183 |
184 | def random_select(self):
185 | if not self.duplicate_groups:
186 | QtWidgets.QMessageBox.warning(self.parent, "警告", "请先扫描重复文件")
187 | return
188 |
189 | self.selected_files.clear()
190 | selected_count = 0
191 |
192 | for group in self.duplicate_groups:
193 | if len(group) > 1:
194 | import random
195 | keep_file = random.choice(group)
196 |
197 | for file_path in group:
198 | if file_path != keep_file:
199 | self.selected_files.add(file_path)
200 | selected_count += 1
201 |
202 | if self.current_group_index >= 0:
203 | self._display_group_files(self.duplicate_groups[self.current_group_index])
204 |
205 | QtWidgets.QMessageBox.information(self.parent, "随机选择",
206 | f"已随机保留每组中一个文件,共选择 {selected_count} 个重复文件")
207 |
208 | def move_to_recycle_bin(self):
209 | if not self.selected_files:
210 | QtWidgets.QMessageBox.warning(self.parent, "警告", "请先选择要删除的文件")
211 | return
212 |
213 | reply = QtWidgets.QMessageBox.question(self.parent, "确认删除",
214 | f"确定要将 {len(self.selected_files)} 个文件移动到回收站吗?",
215 | QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No)
216 |
217 | if reply == QtWidgets.QMessageBox.StandardButton.Yes:
218 | self.deduplicate_thread = FileDeduplicateThread(self.duplicate_groups, list(self.selected_files))
219 | self.deduplicate_thread.progress_updated.connect(self.on_deduplicate_progress)
220 | self.deduplicate_thread.deduplicate_completed.connect(self.on_deduplicate_completed)
221 | self.deduplicate_thread.error_occurred.connect(self.on_deduplicate_error)
222 | self.deduplicate_thread.start()
223 |
224 | logger.info(f"开始删除 {len(self.selected_files)} 个重复文件")
225 |
226 | def on_deduplicate_progress(self, progress, status_text):
227 | self.parent.contrastProgressBar.setValue(progress)
228 |
229 | def on_deduplicate_completed(self, deleted_count, total_count):
230 | QtWidgets.QMessageBox.information(self.parent, "去重完成",
231 | f"成功删除 {deleted_count} 个重复文件")
232 |
233 | self.start_scan()
234 |
235 | def on_deduplicate_error(self, error_message):
236 | QtWidgets.QMessageBox.critical(self.parent, "去重错误", error_message)
237 | logger.error(f"去重错误: {error_message}")
238 |
239 | def stop_scan(self):
240 | if self.scan_thread and self.scan_thread.isRunning():
241 | self.scan_thread.stop()
242 | self.scan_thread.wait()
243 |
244 | if self.deduplicate_thread and self.deduplicate_thread.isRunning():
245 | self.deduplicate_thread.stop()
246 | self.deduplicate_thread.wait()
--------------------------------------------------------------------------------
/add_folder.py:
--------------------------------------------------------------------------------
1 | import os
2 | import logging
3 | from PyQt6 import QtWidgets
4 | from PyQt6.QtCore import pyqtSignal, Qt
5 | from config_manager import config_manager
6 |
7 | logging.basicConfig(level=logging.INFO)
8 | logger = logging.getLogger(__name__)
9 |
10 |
11 | class FolderPage(QtWidgets.QWidget):
12 | folders_changed = pyqtSignal(list)
13 |
14 | def __init__(self, parent):
15 | super().__init__(parent)
16 | self.parent = parent
17 |
18 |
19 | self._load_saved_folders()
20 |
21 | self.parent.btnBrowseSource.clicked.connect(
22 | lambda: self._browse_directory("选择源文件夹", self.parent.inputSourceFolder))
23 | self.parent.btnBrowseTarget.clicked.connect(
24 | lambda: self._browse_directory("选择目标文件夹", self.parent.inputTargetFolder))
25 |
26 |
27 | self.parent.inputSourceFolder.textChanged.connect(
28 | lambda: self._save_folder_path("source_folder", self.parent.inputSourceFolder.text()))
29 | self.parent.inputTargetFolder.textChanged.connect(
30 | lambda: self._save_folder_path("target_folder", self.parent.inputTargetFolder.text()))
31 |
32 | def _load_saved_folders(self):
33 | try:
34 | source_folder = config_manager.get_setting("source_folder", "")
35 | if source_folder and os.path.exists(source_folder):
36 | self.parent.inputSourceFolder.setText(source_folder)
37 | logger.info(f"已加载保存的源文件夹: {source_folder}")
38 | self._update_folder_info_display(source_folder)
39 |
40 | target_folder = config_manager.get_setting("target_folder", "")
41 | if target_folder and os.path.exists(target_folder):
42 | self.parent.inputTargetFolder.setText(target_folder)
43 | logger.info(f"已加载保存的目标文件夹: {target_folder}")
44 |
45 | self._update_import_status("")
46 | except Exception as e:
47 | logger.error(f"加载保存的文件夹路径时出错: {str(e)}")
48 |
49 | def _save_folder_path(self, setting_key, folder_path):
50 | try:
51 | config_manager.update_setting(setting_key, folder_path)
52 | logger.info(f"已保存{setting_key}: {folder_path}")
53 | except Exception as e:
54 | logger.error(f"保存{setting_key}时出错: {str(e)}")
55 |
56 | def _browse_directory(self, title, line_edit):
57 | original_path = line_edit.text()
58 |
59 | try:
60 | selected_path = QtWidgets.QFileDialog.getExistingDirectory(self, title, original_path or "")
61 |
62 | if not selected_path:
63 | return
64 |
65 | if not self._validate_folder_selection(selected_path, title, original_path, line_edit):
66 | return
67 |
68 | line_edit.setText(selected_path)
69 |
70 | if title == "选择源文件夹":
71 | self._update_folder_info_display(selected_path)
72 |
73 | self._update_import_status(title)
74 |
75 | except Exception as e:
76 | logger.error(f"浏览目录时出错: {str(e)}")
77 | line_edit.setText(original_path)
78 |
79 | def _validate_folder_selection(self, selected_path, title, original_path, line_edit):
80 | source_path = self.parent.inputSourceFolder.text().strip()
81 | target_path = self.parent.inputTargetFolder.text().strip()
82 |
83 | if title == "选择源文件夹":
84 | source_path = selected_path
85 | else:
86 | target_path = selected_path
87 |
88 | if source_path and target_path:
89 | if not self._check_folder_relationship(source_path, target_path):
90 | QtWidgets.QMessageBox.warning(self, "文件夹选择错误",
91 | "源文件夹和目标文件夹不能相同,也不能存在隶属关系。",
92 | QtWidgets.QMessageBox.StandardButton.Ok)
93 | line_edit.setText(original_path)
94 | return False
95 |
96 | if title == "选择目标文件夹":
97 | try:
98 | items = os.listdir(selected_path)
99 | if items:
100 | QtWidgets.QMessageBox.warning(self, "请注意,这不是一个空文件夹", "目标文件夹不是一个空文件夹,但不影响您继续。",
101 | QtWidgets.QMessageBox.StandardButton.Ok)
102 | except Exception as e:
103 | logger.warning(f"检查目标文件夹是否为空时出错: {str(e)}")
104 |
105 | return True
106 |
107 | def _update_folder_info_display(self, folder_path):
108 | try:
109 | self.parent.textBrowser_import_info.setText(self.get_folder_info(folder_path))
110 | except Exception as e:
111 | logger.error(f"更新文件夹信息显示时出错: {str(e)}")
112 |
113 | def _update_import_status(self, title):
114 | try:
115 | if title == "选择源文件夹":
116 | self.parent.importStatus.setText("源文件夹已选择")
117 | elif title == "选择目标文件夹":
118 | self.parent.importStatus.setText("目标文件夹已选择")
119 |
120 | source_path = self.parent.inputSourceFolder.text().strip()
121 | target_path = self.parent.inputTargetFolder.text().strip()
122 | if source_path and target_path:
123 | self.parent.importStatus.setText("文件夹导入完成")
124 | except Exception as e:
125 | logger.error(f"更新导入状态时出错: {str(e)}")
126 |
127 | def _check_folder_relationship(self, folder1, folder2):
128 | if not folder1 or not folder2:
129 | return True
130 |
131 | normalized_folder1 = os.path.normpath(os.path.abspath(folder1)).lower()
132 | normalized_folder2 = os.path.normpath(os.path.abspath(folder2)).lower()
133 |
134 | if normalized_folder1 == normalized_folder2:
135 | logger.warning(f"文件夹相同: {normalized_folder1}")
136 | return False
137 |
138 | folder1_full = os.path.join(normalized_folder1, '')
139 | folder2_full = os.path.join(normalized_folder2, '')
140 |
141 | if folder1_full.startswith(folder2_full):
142 | logger.warning(f"文件夹1包含文件夹2: {folder1_full} -> {folder2_full}")
143 | return False
144 |
145 | if folder2_full.startswith(folder1_full):
146 | logger.warning(f"文件夹2包含文件夹1: {folder2_full} -> {folder1_full}")
147 | return False
148 |
149 | return True
150 |
151 | def get_all_folders(self):
152 | source_path = self.parent.inputSourceFolder.text().strip()
153 | if source_path and os.path.exists(source_path) and os.path.isdir(source_path):
154 | return [source_path]
155 | elif source_path:
156 | logger.warning("源文件夹不存在或不是有效目录")
157 | return []
158 |
159 | def get_target_folder(self):
160 | target_path = self.parent.inputTargetFolder.text().strip()
161 | if target_path and os.path.exists(target_path) and os.path.isdir(target_path):
162 | return target_path
163 | elif target_path:
164 | logger.warning("目标文件夹不存在或不是有效目录")
165 | return None
166 |
167 | def get_folder_info(self, folder_path):
168 | info_lines = ["=" * 15 + "待处理的源文件夹信息" + "=" * 15, f"路径:{folder_path}"]
169 |
170 | file_count = 0
171 | directory_count = 0
172 | total_items = 0
173 | skipped_items_count = 0
174 |
175 | try:
176 | if os.path.exists(folder_path) and os.path.isdir(folder_path):
177 | try:
178 | items = os.listdir(folder_path)
179 | total_items = len(items)
180 |
181 | for item_name in items:
182 | item_full_path = os.path.join(folder_path, item_name)
183 | try:
184 | if os.path.isfile(item_full_path):
185 | file_count += 1
186 | elif os.path.isdir(item_full_path):
187 | directory_count += 1
188 | except Exception:
189 | skipped_items_count += 1
190 | continue
191 | except Exception:
192 | pass
193 | except Exception:
194 | pass
195 |
196 | if skipped_items_count > 0:
197 | info_lines.append(f"\n注意:跳过了 {skipped_items_count} 个无法访问的项目")
198 |
199 | info_lines.extend(["", "-" * 20 + "注意!会递归遍历处理子文件夹" + "-" * 20, "顶层内容统计",
200 | f"顶层文件数量:{file_count} 顶层文件夹数量:{directory_count} 总计:{total_items}",
201 | "-" * 75, " LeafSort(轻羽媒体整理) © 2025 Yangshengzhou.All Rights Reserved "])
202 |
203 | return '\n'.join(info_lines)
204 |
205 | def _import_folders(self, folders):
206 | if not folders:
207 | logger.warning("没有提供要导入的文件夹")
208 | return
209 |
210 | try:
211 | imported_count = 0
212 | conflict_count = 0
213 |
214 | for folder in folders:
215 | logger.info(f"尝试导入文件夹: {folder}")
216 |
217 | has_conflict = False
218 | try:
219 | for item in self.parent.lst_folders.findItems('', Qt.MatchContains):
220 | if not self._check_folder_relationship(folder, item.text()):
221 | has_conflict = True
222 | conflict_count += 1
223 | logger.warning(f"与现有文件夹冲突: {folder} 和 {item.text()}")
224 | break
225 | except Exception as e:
226 | logger.error(f"检查文件夹冲突时出错: {str(e)}")
227 | continue
228 |
229 | target_folder = self.get_target_folder()
230 | if not has_conflict and target_folder and not self._check_folder_relationship(folder, target_folder):
231 | has_conflict = True
232 | conflict_count += 1
233 | logger.warning(f"与目标文件夹冲突: {folder} 和 {target_folder}")
234 |
235 | if not has_conflict and self._is_folder_duplicate(folder):
236 | has_conflict = True
237 | conflict_count += 1
238 | logger.warning(f"文件夹重复: {folder}")
239 |
240 | if not has_conflict:
241 | self.parent.lst_folders.addItem(folder)
242 | imported_count += 1
243 | logger.info(f"成功导入文件夹: {folder}")
244 |
245 | self._update_status_label(imported_count > 0,
246 | f"成功导入 {imported_count} 个文件夹" if imported_count > 0 else f"未导入文件夹,存在 {conflict_count} 个冲突")
247 |
248 | except Exception as e:
249 | logger.error(f"导入文件夹时出错: {str(e)}")
250 | self._update_status_label(False, "导入文件夹时出错")
251 | finally:
252 | self.parent.btn_browse_folder.setEnabled(True)
253 | self.parent.btn_import.setEnabled(True)
254 | logger.info("文件夹导入操作完成")
255 |
256 | def _update_status_label(self, success, message=None):
257 | if success:
258 | status_text = message if message else "导入成功!"
259 | self.parent.status_label.setText(status_text)
260 | self.parent.status_label.setStyleSheet("QLabel { color: green; }")
261 | logger.info(f"状态更新: {status_text}")
262 | else:
263 | status_text = message if message else "导入失败,请检查文件夹路径是否正确或与现有文件夹有冲突"
264 | self.parent.status_label.setText(status_text)
265 | self.parent.status_label.setStyleSheet("QLabel { color: red; }")
266 | logger.warning(f"状态更新: {status_text}")
267 |
268 | def _is_folder_duplicate(self, folder):
269 | try:
270 | normalized_folder = os.path.normpath(os.path.abspath(folder)).lower()
271 |
272 | for item in self.parent.lst_folders.findItems('', Qt.MatchContains):
273 | try:
274 | normalized_item = os.path.normpath(os.path.abspath(item.text())).lower()
275 | if normalized_folder == normalized_item:
276 | return True
277 | except Exception as e:
278 | logger.warning(f"检查重复文件夹时出错 (item: {item.text()}): {str(e)}")
279 | continue
280 | except Exception as e:
281 | logger.error(f"检查重复文件夹时出错 (folder: {folder}): {str(e)}")
282 |
283 | return False
284 |
--------------------------------------------------------------------------------
/resources/img/page_0/空状态.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/config_manager.py:
--------------------------------------------------------------------------------
1 | import json
2 | import logging
3 | import os
4 | import threading
5 | from datetime import datetime
6 | from typing import Dict, List, Any, Optional
7 |
8 | def get_app_data_path():
9 | local_app_data = os.environ.get('LOCALAPPDATA')
10 | if local_app_data:
11 | app_data_path = os.path.join(local_app_data, 'LeafSort')
12 | os.makedirs(app_data_path, exist_ok=True)
13 | return app_data_path
14 |
15 | return os.path.dirname(os.path.abspath(__file__))
16 |
17 | logger = logging.getLogger(__name__)
18 |
19 | def _thread_safe_method(func):
20 | def wrapper(self, *args, **kwargs):
21 | with self._lock:
22 | return func(self, *args, **kwargs)
23 | return wrapper
24 |
25 | class ConfigManager:
26 | CONFIG_VERSION = "1.1"
27 |
28 | def __init__(self):
29 | self._lock = threading.RLock()
30 | self.app_data_path = get_app_data_path()
31 | self.internal_dir = os.path.join(self.app_data_path, '_internal')
32 | os.makedirs(self.internal_dir, exist_ok=True)
33 |
34 | self.config_file = os.path.join(self.internal_dir, 'config.json')
35 | self._ensure_config_exists()
36 | self.config = self._load_file()
37 | self.location_cache = {}
38 | self.cache_file = os.path.join(self.internal_dir, 'cache_location.json')
39 | self._load_location_cache()
40 | self._validate_and_migrate_config()
41 |
42 | def _ensure_config_exists(self) -> None:
43 |
44 | os.makedirs(self.internal_dir, exist_ok=True)
45 |
46 |
47 | if not os.path.exists(self.config_file):
48 | default_config = self._get_default_config()
49 | try:
50 | with open(self.config_file, 'w', encoding='utf-8') as f:
51 | json.dump(default_config, f, indent=4, ensure_ascii=False)
52 | logger.info(f"创建默认配置文件: {self.config_file}")
53 | except Exception as e:
54 | logger.error(f"创建默认配置文件失败: {str(e)}")
55 |
56 | def _load_file(self) -> Dict[str, Any]:
57 | try:
58 | with open(self.config_file, 'r', encoding='utf-8') as f:
59 | return json.load(f)
60 | except (json.JSONDecodeError, IOError) as e:
61 | logger.error(f"加载配置文件失败: {str(e)}")
62 | return self._get_default_config()
63 |
64 | def _save_file_no_lock(self) -> bool:
65 | self.config["last_modified"] = datetime.now().isoformat()
66 | try:
67 | with open(self.config_file, 'w', encoding='utf-8') as f:
68 | json.dump(self.config, f, indent=4, ensure_ascii=False)
69 | return True
70 | except Exception as e:
71 | logger.error(f"保存配置文件失败: {str(e)}")
72 | return False
73 |
74 | def _validate_and_migrate_config(self) -> None:
75 | default_config = self._get_default_config()
76 |
77 | if "version" not in self.config or self.config["version"] != self.CONFIG_VERSION:
78 | for key, value in default_config.items():
79 | if key not in self.config:
80 | self.config[key] = value.copy() if isinstance(value, dict) else value
81 |
82 | for key, value in default_config["settings"].items():
83 | if key not in self.config["settings"]:
84 | self.config["settings"][key] = value
85 |
86 | if "api_limits" not in self.config:
87 | self.config["api_limits"] = default_config["api_limits"]
88 | elif "gaode" not in self.config["api_limits"]:
89 | self.config["api_limits"]["gaode"] = default_config["api_limits"]["gaode"]
90 | else:
91 | self.config["api_limits"]["gaode"]["max_daily_calls"] = 300
92 |
93 | self.config["version"] = self.CONFIG_VERSION
94 | self._save_file_no_lock()
95 |
96 | @_thread_safe_method
97 | def add_folder(self, folder_path: str) -> bool:
98 | if folder_path not in self.config["folders"]:
99 | self.config["folders"].append(folder_path)
100 | return self._save_file_no_lock()
101 | return True
102 |
103 | @_thread_safe_method
104 | def remove_folder(self, folder_path: str) -> bool:
105 | if folder_path in self.config["folders"]:
106 | self.config["folders"].remove(folder_path)
107 | return self._save_file_no_lock()
108 | return True
109 |
110 | @_thread_safe_method
111 | def get_folders(self) -> List[str]:
112 | return self.config["folders"].copy()
113 |
114 | @_thread_safe_method
115 | def has_folder(self, folder_path: str) -> bool:
116 | return folder_path in self.config["folders"]
117 |
118 | def _load_location_cache(self) -> None:
119 | try:
120 | if os.path.exists(self.cache_file):
121 | with open(self.cache_file, 'r', encoding='utf-8') as f:
122 | self.location_cache = json.load(f)
123 | self._cleanup_expired_cache()
124 | except Exception as e:
125 | logger.error(f"加载位置缓存失败: {str(e)}")
126 | self.location_cache = {}
127 |
128 | def _save_location_cache_no_lock(self) -> bool:
129 | try:
130 | with open(self.cache_file, 'w', encoding='utf-8') as f:
131 | json.dump(self.location_cache, f, indent=4, ensure_ascii=False)
132 | return True
133 | except Exception as e:
134 | logger.error(f"保存位置缓存失败: {str(e)}")
135 | return False
136 |
137 | @_thread_safe_method
138 | def save_location_cache(self) -> bool:
139 | return self._save_location_cache_no_lock()
140 |
141 | @_thread_safe_method
142 | def cache_location(self, latitude: float, longitude: float, address: str) -> bool:
143 | cache_key = f"{latitude},{longitude}"
144 | current_time = datetime.now().isoformat()
145 |
146 | if len(self.location_cache) >= self.config["cache_settings"]["max_location_cache_size"]:
147 | self._cleanup_expired_cache()
148 |
149 | if len(self.location_cache) >= self.config["cache_settings"]["max_location_cache_size"]:
150 | oldest_key = min(self.location_cache.keys(), key=lambda k: self.location_cache[k]["last_access"])
151 | del self.location_cache[oldest_key]
152 |
153 | self.location_cache[cache_key] = {
154 | "address": address,
155 | "last_access": current_time
156 | }
157 |
158 | return self._save_location_cache_no_lock()
159 |
160 | def _cleanup_expired_cache(self) -> None:
161 | expiry_days = self.config["cache_settings"]["cache_expiry_days"]
162 |
163 | if expiry_days <= 0:
164 | return
165 |
166 | current_time = datetime.now()
167 |
168 | expired_keys = []
169 | for key, data in self.location_cache.items():
170 | try:
171 | last_access = datetime.fromisoformat(data["last_access"])
172 | days_since_access = (current_time - last_access).days
173 |
174 | if days_since_access > expiry_days:
175 | expired_keys.append(key)
176 | except Exception:
177 | expired_keys.append(key)
178 |
179 | for key in expired_keys:
180 | if key in self.location_cache:
181 | del self.location_cache[key]
182 |
183 | def _update_cache_access_time(self, cache_data: Dict[str, str]) -> None:
184 | cache_data["last_access"] = datetime.now().isoformat()
185 |
186 | def _get_cache_key(self, latitude: float, longitude: float) -> str:
187 | return f"{latitude},{longitude}"
188 |
189 | def _get_cached_location_no_lock(self, latitude: float, longitude: float) -> Optional[str]:
190 | cache_key = self._get_cache_key(latitude, longitude)
191 | if cache_key in self.location_cache:
192 | return self.location_cache[cache_key]["address"]
193 | return None
194 |
195 | @_thread_safe_method
196 | def get_cached_location(self, latitude: float, longitude: float) -> Optional[str]:
197 | result = self._get_cached_location_no_lock(latitude, longitude)
198 | if result:
199 | key = self._get_cache_key(latitude, longitude)
200 | if key in self.location_cache:
201 | self._update_cache_access_time(self.location_cache[key])
202 | threading.Thread(target=self._save_location_cache_no_lock, daemon=True).start()
203 | return result
204 |
205 | @_thread_safe_method
206 | def get_cached_location_with_tolerance(self, latitude: float, longitude: float, tolerance: float = 0.02) -> Optional[str]:
207 | exact_match = self._get_cached_location_no_lock(latitude, longitude)
208 | if exact_match:
209 | return exact_match
210 |
211 | tolerance_squared = tolerance ** 2
212 | for cache_key, cached_data in self.location_cache.items():
213 | try:
214 | cached_lat, cached_lon = map(float, cache_key.split(','))
215 | distance_squared = ((latitude - cached_lat) ** 2 + (longitude - cached_lon) ** 2)
216 | if distance_squared <= tolerance_squared:
217 | self._update_cache_access_time(cached_data)
218 | threading.Thread(target=self._save_location_cache_no_lock, daemon=True).start()
219 | return cached_data["address"]
220 | except (ValueError, IndexError):
221 | continue
222 |
223 | return None
224 |
225 | @_thread_safe_method
226 | def clear_folders(self) -> bool:
227 | self.config["folders"] = []
228 | return self._save_file_no_lock()
229 |
230 | @_thread_safe_method
231 | def update_setting(self, key: str, value: Any) -> bool:
232 | if not self._validate_setting(key, value):
233 | return False
234 |
235 | self.config["settings"][key] = value
236 | return self._save_file_no_lock()
237 |
238 |
239 | @_thread_safe_method
240 | def get_setting(self, key: str, default: Any = None) -> Any:
241 | return self.config["settings"].get(key, default)
242 |
243 | @_thread_safe_method
244 | def get_settings(self, keys: List[str] = None) -> Dict[str, Any]:
245 | if keys:
246 | return {key: self.config["settings"].get(key) for key in keys}
247 |
248 | return self.config["settings"].copy()
249 |
250 | @_thread_safe_method
251 |
252 |
253 | def _validate_setting(self, key: str, value: Any) -> bool:
254 | validation_rules = {
255 | "thumbnail_size": lambda v: isinstance(v, int) and 0 < v <= 2000,
256 | "max_cache_size": lambda v: isinstance(v, int) and v >= 0,
257 | "default_view": lambda v: isinstance(v, str) and v in ["grid", "list"],
258 | "map_provider": lambda v: isinstance(v, str) and v in ["gaode", "baidu", "bing", "google"]
259 | }
260 |
261 | if key in ["dark_mode", "auto_update_metadata", "use_gps_cache", "show_thumbnails", "enable_exif_edit", "remember_window_size"]:
262 | return isinstance(value, bool)
263 |
264 | if key in validation_rules:
265 | return validation_rules[key](value)
266 |
267 | return True
268 |
269 | def _get_default_config(self) -> Dict[str, Any]:
270 | current_time = datetime.now()
271 | return {
272 | "version": self.CONFIG_VERSION,
273 | "last_modified": current_time.isoformat(),
274 | "folders": [],
275 | "settings": {
276 | "thumbnail_size": 200,
277 | "max_cache_size": 10000,
278 | "default_view": "grid",
279 | "dark_mode": False,
280 | "auto_update_metadata": True,
281 | "use_gps_cache": True,
282 | "map_provider": "gaode",
283 | "show_thumbnails": True,
284 | "enable_exif_edit": True,
285 | "remember_window_size": True,
286 | "max_threads": 4,
287 | "memory_limit_mb": 2048,
288 | "last_opened_folder": '',
289 | "window_position": {'x': 100, 'y': 100},
290 | "window_size": {'width': 942, 'height': 580},
291 | "location_cache_tolerance": 0.027,
292 | "source_folder": '',
293 | "target_folder": ''
294 | },
295 | "api_limits": {
296 | "gaode": {
297 | "daily_calls": 0,
298 | "max_daily_calls": 100,
299 | "last_reset_date": current_time.strftime("%Y-%m-%d"),
300 | "last_call_time": None
301 | }
302 | },
303 | "cache_settings": {
304 | "max_location_cache_size": 500000,
305 | "cache_expiry_days": 0
306 | }
307 | }
308 |
309 | @_thread_safe_method
310 | def _check_and_reset_daily_limit(self) -> None:
311 | current_date = datetime.now().strftime("%Y-%m-%d")
312 | if self.config["api_limits"]["gaode"]["last_reset_date"] != current_date:
313 | self.config["api_limits"]["gaode"]["daily_calls"] = 0
314 | self.config["api_limits"]["gaode"]["last_reset_date"] = current_date
315 | self._save_file_no_lock()
316 |
317 | @_thread_safe_method
318 | def can_make_api_call(self) -> bool:
319 | self._check_and_reset_daily_limit()
320 | current_calls = self.config["api_limits"]["gaode"]["daily_calls"]
321 | max_calls = self.config["api_limits"]["gaode"]["max_daily_calls"]
322 | return current_calls < max_calls
323 |
324 | @_thread_safe_method
325 | def increment_api_call(self) -> None:
326 | self._check_and_reset_daily_limit()
327 | self.config["api_limits"]["gaode"]["daily_calls"] += 1
328 | self.config["api_limits"]["gaode"]["last_call_time"] = datetime.now().isoformat()
329 | self._save_file_no_lock()
330 |
331 | @_thread_safe_method
332 | def get_remaining_daily_calls(self) -> int:
333 | try:
334 | with self._lock:
335 | self._check_and_reset_daily_limit()
336 | return max(0, 365 - self.config["daily_api_calls"])
337 | except Exception as e:
338 | logger.error(f"Error getting remaining API calls: {str(e)}")
339 | return 0
340 |
341 | @_thread_safe_method
342 | def clear_cache(self):
343 | self.location_cache.clear()
344 | save_result = self.save_location_cache()
345 |
346 | if self.cache_file:
347 | try:
348 | os.remove(self.cache_file)
349 | except Exception as e:
350 | logger.error(f"清理缓存文件时出错: {str(e)}")
351 | return False
352 |
353 | return save_result
354 |
355 | config_manager = ConfigManager()
--------------------------------------------------------------------------------
/README_EN.md:
--------------------------------------------------------------------------------
1 |
17 |
18 | ## Project Introduction
19 |
20 | LeafSort is a powerful image management tool focused on efficiently managing, intelligently organizing, and batch processing various image files.
21 |
22 | ### Core Features
23 |
24 | - **Efficient Image Management**: Supports multiple image formats, quick browsing and management of large numbers of images
25 | - **Intelligent Organization**: Automatically classify and organize images by time, location, device, and other dimensions
26 | - **Duplicate Detection**: Intelligently identify similar images based on perceptual hash algorithms
27 | - **EXIF Editing**: View and modify image metadata information
28 |
29 | - **Batch Processing**: Supports batch renaming operations
30 |
31 | ## Technical Architecture
32 |
33 | ### Tech Stack
34 |
35 | - **Programming Language**: Python 3.11+
36 | - **GUI Framework**: PyQt6 6.5.0+
37 | - **Image Processing**: Pillow 11.3.0+
38 |
39 | - **Metadata Processing**: piexif, exiftool
40 | - **Other Dependencies**: numpy, scikit-image, opencv-python
41 |
42 | ### Architecture Design
43 |
44 | LeafSort adopts a three-tier architecture design to ensure code maintainability and scalability:
45 |
46 | 1. **User Interface Layer**: Modern GUI interface built on PyQt6
47 | 2. **Business Logic Layer**: Handles core business logic and feature implementation
48 | 3. **Data Processing Layer**: Responsible for file operations, metadata processing, and database interactions
49 |
50 | ## Project Structure
51 |
52 | ```
53 | LeafSort/ # Project root directory
54 | ├── App.py # Application entry point
55 | ├── main_window.py # Main window implementation
56 | ├── Ui_MainWindow.py # UI interface file
57 | ├── Ui_MainWindow.ui # Qt designer interface file
58 | ├── add_folder.py # Folder management functionality
59 | ├── smart_arrange.py # Smart arrangement service
60 | ├── smart_arrange_thread.py # Smart arrangement thread
61 | ├── write_exif.py # EXIF editing functionality
62 | ├── write_exif_thread.py # EXIF editing thread
63 | ├── file_deduplication.py # File deduplication functionality
64 | ├── file_deduplication_thread.py # File deduplication thread
65 | ├── common.py # Common functions
66 | ├── config_manager.py # Configuration manager
67 | ├── update_dialog.py # Update dialog
68 | ├── UI_UpdateDialog.py # Update dialog UI
69 | ├── UI_UpdateDialog.ui # Qt designer interface file
70 | ├── License\ # License related files
71 | ├── resources/ # Resource files
72 | │ ├── cv2_date/ # OpenCV related files
73 | │ ├── exiftool/ # EXIF tool
74 | │ ├── img/ # Image resources
75 | │ ├── json/ # JSON data files
76 | │ └── stylesheet/ # Stylesheet files
77 | ├── requirements.txt # Dependency list
78 | ├── README.md # Project description (Chinese)
79 | └── README_EN.md # Project description (English)
80 | ```
81 |
82 | ## Installation and Deployment
83 |
84 | ### Environment Requirements
85 |
86 | - Python 3.11 or higher
87 | - Windows 10/11, macOS 12+, Linux (Ubuntu 20.04+)
88 |
89 |
90 | ### Source Installation
91 |
92 | 1. Clone the repository
93 | ```bash
94 | git clone https://github.com/YangShengzhou03/LeafSort.git
95 | cd LeafSort
96 | ```
97 |
98 | 2. Install dependencies
99 | ```bash
100 | pip install -r requirements.txt
101 | ```
102 |
103 | 3. Run the application
104 | ```bash
105 | python App.py
106 | ```
107 |
108 | ### Pre-compiled Version
109 |
110 | You can download pre-compiled executable files for your platform from the [GitHub Releases](https://github.com/YangShengzhou03/LeafSort/releases) page.
111 |
112 | ### Packaging as Executable
113 |
114 | ```bash
115 | # Windows
116 | pyinstaller -w -F --icon=resources/img/icon.ico App.py
117 | ```
118 |
119 | ## User Guide
120 |
121 | ### Basic Operations
122 |
123 | 1. **Import Images**: Import images through the "File" menu or by dragging and dropping
124 | 2. **Browse Images**: Use mouse wheel, arrow keys, or thumbnail list to browse
125 | 3. **View Details**: Click the "Details" button to view image metadata
126 | 4. **Batch Processing**: Select multiple images and use the right-click menu for batch operations
127 |
128 | ### Intelligent Organization
129 |
130 | The intelligent organization feature supports the following classification methods and file formats:
131 |
132 | #### Supported File Formats
133 | - **Image Formats**: `.jpg`, `.jpeg`, `.png`, `.bmp`, `.gif`, `.webp`, `.tiff`, `.heic`, `.heif`, `.svg`
134 | - **Video Formats**: `.mp4`, `.avi`, `.mov`, `.wmv`, `.flv`, `.mkv`, `.webm`, `.m4v`, `.3gp`
135 | - **Audio Formats**: `.mp3`, `.wav`, `.flac`, `.aac`, `.ogg`, `.wma`, `.m4a`
136 |
137 | #### Classification Methods and Extracted Information
138 |
139 | **By Time**:
140 | - Reads EXIF `DateTimeOriginal` field
141 | - Supports Year/Month/Day hierarchical structure
142 | - Example: `2024/01/15_filename.jpg`
143 |
144 | **By Location**:
145 | - Based on GPS coordinates for geocoding
146 | - Supports automatic province/city recognition
147 | - Example: `Beijing/Chaoyang_filename.jpg`
148 |
149 | **By Device**:
150 | - **Camera Brand**: Reads EXIF `Make` field (camera brand, phone brand)
151 | - **Camera Model**: Reads EXIF `Model` field (specific device model)
152 | - Supports mainstream camera and phone brand recognition
153 | - Example: `Apple/iPhone_15_Pro_filename.jpg`
154 |
155 | **By File Type**:
156 | - Uses file extension for classification
157 | - Example: `JPEG/PNG/HEIC/filename.jpg`
158 |
159 | **Multi-level Classification**:
160 | - Supports up to 4-level classification combinations
161 | - Customizable separators (-, _, space, none, etc.)
162 | - Example: `2024/Apple/iPhone_15_Pro/JPEG/filename.jpg`
163 |
164 | #### Operation Steps
165 | 1. Select images or folders to organize
166 | 2. Click the "Intelligent Organization" button
167 | 3. Choose target folder path
168 | 4. Configure classification rules (supports multi-level combinations)
169 | 5. Click "Start Organization" to execute the operation
170 |
171 | ### Duplicate Detection
172 |
173 | The deduplication feature can intelligently identify and remove duplicate files, supporting:
174 |
175 | #### Supported Formats and Detection Method
176 | - **Supported Formats**: All image formats including `.jpg`, `.jpeg`, `.png`, `.bmp`, `.gif`, `.webp`, `.tiff`, `.heic`, `.heif`, `.svg`
177 | - **Algorithm**: Perceptual Hash (pHash) algorithm for perceptual similarity comparison
178 | - **Smart Recognition**: Automatically detects screenshots, similar photos with minor differences, and variations taken in burst mode
179 | - **Content-Based Detection**: Considers image content, color distribution, and visual features
180 |
181 | #### Operation Steps
182 | 1. Select the folder containing files to scan
183 | 2. Click "Duplicate Detection" button
184 | 3. Configure detection parameters:
185 | - **Similarity Threshold**: Adjust from 1-100 (default 90)
186 | - **Detection Method**: Choose from file hash, perceptual hash, or combined detection
187 | - **Include Subdirectories**: Option to scan subfolders
188 | 4. Click "Start Detection" to execute the operation
189 | 5. Review detected duplicate groups:
190 | - **Auto-grouping**: Automatically groups similar files by similarity
191 | - **Preview Comparison**: Side-by-side comparison of duplicate files
192 | - **Smart Selection**: Automatically selects lower quality duplicates for deletion
193 | 6. Select files to delete or move to trash
194 |
195 | #### Duplicate File Handling Features
196 | - **Batch Operations**: Select multiple duplicate groups for simultaneous processing
197 | - **Safe Deletion**: Option to move to recycle bin instead of permanent deletion
198 | - **Preview Before Deletion**: Visual confirmation before removing files
199 | - **Keep Original**: Preserves the highest quality or earliest created file
200 |
201 | ### EXIF Editing
202 |
203 | #### Supported File Formats and Editable Properties
204 |
205 | **JPEG/PNG/WebP Formats**:
206 | - **Title** (Title/ImageDescription)
207 | - **Author** (Artist/Creator)
208 | - **Rating** (Rating) - 1-5 star rating system
209 | - **Capture Time** (DateTimeOriginal) - Supports custom time source
210 | - **Device Information** (Make/Model) - Supports mainstream camera and phone brands
211 | - **Lens Information** (LensModel/LensMake) - Automatic matching based on device
212 | - **Geographic Location** (GPS related tags) - Supports latitude/longitude writing
213 | - **Copyright Information** (Copyright)
214 | - **Keywords/Tags** (XPKeywords, etc.)
215 |
216 | **HEIC/HEIF Formats**:
217 | - **Title and Description** (partial support)
218 | - **Author Information** (limited support)
219 | - **Rating** (partial device support)
220 | - **Capture Time**
221 | - **Device Information**
222 | - *Note: Some property support may be limited depending on device compatibility*
223 |
224 | **Video Formats (MOV/MP4/AVI/MKV)**:
225 | - **Title** (Title)
226 | - **Author/Creator** (Author/Creator)
227 | - **Rating** (limited support)
228 | - **Capture Time**
229 | - **Basic Device Information**
230 | - *Note: Video metadata support is relatively simple, mainly for basic identification*
231 |
232 | **RAW Formats (CR2/CR3/NEF/ARW/ORF/DNG/RAF)**:
233 | - **Title** (Title)
234 | - **Author Information**
235 | - **Rating** (limited by format specifications)
236 | - **Capture Time**
237 | - **Partial Device Information**
238 | - *Note: RAW format metadata writing is restricted by camera manufacturer format limitations*
239 |
240 | #### Device Information Database
241 | Built-in rich device information database, supporting:
242 | - **Camera Brands**: Canon, Nikon, Sony, Fujifilm, Leica, Panasonic, Olympus, Pentax, Sigma, etc.
243 | - **Phone Brands**: Apple, Xiaomi, Huawei, OPPO, vivo, OnePlus, Samsung, Google, etc.
244 | - **Automatic Lens Matching**: Automatically matches corresponding lens information based on camera model
245 |
246 | #### Operation Steps
247 | 1. Select the images you want to edit
248 | 2. Click the "Edit EXIF" button
249 | 3. Configure editing options:
250 | - **Time Setting**: Use file time/EXIF time/custom time
251 | - **Device Information**: Select camera brand and model (automatic lens matching)
252 | - **Rating Setting**: 1-5 star rating
253 | - **Basic Information**: Title, author, copyright, etc.
254 | 4. Click "Start Writing EXIF Information" to apply modifications
255 |
256 | #### Important Reminders
257 | - **Irreversible Operation**: EXIF writing cannot be undone once completed, recommend backing up original files first
258 | - **Format Compatibility**: Different formats have varying levels of EXIF support with compatibility differences
259 | - **HEIC Support**: Requires additional decoder support (pillow-heif library)
260 | - **Geographic Information**: Requires images to contain GPS data to retrieve and set location informationchanges
261 |
262 |
263 |
264 |
265 |
266 | ## Advanced Features
267 |
268 |
269 |
270 | ### Performance Optimization
271 |
272 | - **Cache Mechanism**: Thumbnail and metadata caching
273 | - **Batch Processing**: Process large image collections in batches
274 | - **Concurrent Processing**: Use multi-threading to optimize IO-intensive tasks
275 |
276 | ## Troubleshooting
277 |
278 | ### Common Issues
279 |
280 | 1. **Module Import Error**
281 | - Ensure all dependencies are correctly installed: `pip install -r requirements.txt --upgrade`
282 |
283 | 2. **HEIC/HEIF Format Support**
284 | - Install pillow-heif library: `pip install pillow-heif --upgrade`
285 |
286 |
287 |
288 | 4. **High Memory Usage**
289 | - Reduce cache size or process images in batches
290 |
291 | ### Error Code Reference
292 |
293 | | Error Code | Description | Solution |
294 | |----------|------|----------|
295 | | ERR-001 | File does not exist or cannot be accessed | Check file path and permissions |
296 | | ERR-002 | Unsupported file format | Install the corresponding decoder library |
297 | | ERR-003 | Insufficient memory | Reduce processing batch or increase memory |
298 | | ERR-004 | Insufficient disk space | Clean up disk space |
299 | | ERR-005 | Network connection failure | Check network settings |
300 | | ERR-006 | Permission denied | Run with administrator privileges |
301 | | ERR-007 | Missing dependency libraries | Reinstall dependency libraries |
302 | | ERR-008 | Configuration file corrupted | Delete configuration file and regenerate |
303 |
304 | ## Community Support
305 |
306 | ### Contact Information
307 |
308 | - **Author**: YangShengzhou03
309 | - **GitHub**: [https://github.com/YangShengzhou03](https://github.com/YangShengzhou03)
310 | - **Issue Feedback**: [GitHub Issues](https://github.com/YangShengzhou03/LeafSort/issues)
311 | - **Discussion Area**: [GitHub Discussions](https://github.com/YangShengzhou03/LeafSort/discussions)
312 |
313 | ## License
314 |
315 | ```
316 | MIT License
317 |
318 | Copyright (c) 2024 YangShengzhou03
319 |
320 | Permission is hereby granted, free of charge, to any person obtaining a copy
321 | of this software and associated documentation files (the "Software"), to deal
322 | in the Software without restriction, including without limitation the rights
323 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
324 | copies of the Software, and to permit persons to whom the Software is
325 | furnished to do so, subject to the following conditions:
326 |
327 | The above copyright notice and this permission notice shall be included in all
328 | copies or substantial portions of the Software.
329 |
330 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
331 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
332 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
333 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
334 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
335 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
336 | SOFTWARE.
337 | ```
338 |
339 | ## Update Log
340 |
341 | ### v1.0.0 (2024-XX-XX)
342 |
343 | #### New Features
344 | - Initial version release
345 | - Implemented basic image browsing and management functions
346 | - Intelligent organization feature: supports multi-dimensional classification by time, device, etc.
347 | - Deduplication feature: detects duplicate images based on perceptual hash algorithm
348 | - EXIF editing feature: view and modify image metadata
349 |
350 |
351 | #### Fixed Issues
352 | - Fixed issue where HEIC/HEIF format images cannot be opened
353 | - Fixed memory leak issue when processing large files
354 | - Fixed race conditions in multi-threaded processing
355 |
356 | #### Performance Optimization
357 | - Optimized thumbnail generation and caching mechanism
358 | - Improved large batch file processing performance
359 | - Reduced memory usage and improved operational stability
360 |
361 |
362 |
Built with Python and PyQt6
363 |
--------------------------------------------------------------------------------
/common.py:
--------------------------------------------------------------------------------
1 | import json
2 | import logging
3 | import os
4 | from typing import Dict, Tuple, Optional
5 |
6 | import requests
7 | from playwright.sync_api import sync_playwright
8 |
9 | logging.basicConfig(level=logging.INFO)
10 | logger = logging.getLogger(__name__)
11 |
12 | IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp', '.tiff', '.heic', '.heif', '.svg']
13 | VIDEO_EXTENSIONS = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.webm', '.m4v', '.3gp']
14 | AUDIO_EXTENSIONS = ['.mp3', '.wav', '.flac', '.aac', '.ogg', '.wma', '.m4a']
15 |
16 | MAGIC_NUMBERS = {
17 | b'\xff\xd8\xff': ('.jpg', '图像'),
18 | b'\x89\x50\x4E\x47': ('.png', '图像'),
19 | b'GIF8': ('.gif', '图像'),
20 | b'GIF9': ('.gif', '图像'),
21 | b'BM': ('.bmp', '图像'),
22 | b'\x42\x4D': ('.bmp', '图像'),
23 | b'RIFF': ('.avi', '视频'),
24 | b'ftyp': ('.mp4', '视频'),
25 | b'\x00\x00\x00\x1Cftyp': ('.mp4', '视频'),
26 | b'\x00\x00\x00\x20ftyp': ('.mp4', '视频'),
27 | b'\x1A\x45\xDF\xA3': ('.mkv', '视频'),
28 | b'\x4F\x67\x67\x53': ('.ogv', '视频'),
29 | b'WEBP': ('.webp', '图像'),
30 | b'II*': ('.tiff', '图像'),
31 | b'MM*': ('.tiff', '图像'),
32 | b'ftypheic': ('.heic', '图像'),
33 | b'ftypheix': ('.heic', '图像'),
34 | b'ftypmif1': ('.heif', '图像'),
35 | b'ftypmsf1': ('.heif', '图像'),
36 | b'%PDF': ('.pdf', '文档'),
37 | b'PK\x03\x04': ('.zip', '压缩包'),
38 | b'Rar!': ('.rar', '压缩包'),
39 | b'7z\xBC\xAF\x27\x1C': ('.7z', '压缩包'),
40 | b'ID3': ('.mp3', '音乐'),
41 | b'\x52\x49\x46\x46': ('.wav', '音乐'),
42 | b'