├── 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 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /resources/img/窗口控制/最小化.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 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 |
2 |

LeafSort(轻羽媒体整理)

3 |

高效、智能的图片管理工具

4 | 5 |

6 | 7 | GitHub Stars 8 | 9 | GitHub Forks 10 | 11 | GitHub Issues 12 | 13 | MIT License 14 | 15 | 16 | 17 | 18 |

19 |
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 |
2 |

LeafSort - Maple Leaf Photo Gallery

3 |

Efficient and intelligent image management tool

4 | 5 |

6 | 7 | GitHub Stars 8 | 9 | GitHub Forks 10 | 11 | GitHub Issues 12 | 13 | MIT License 14 | 15 |

16 |
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' Optional[Dict[str, str]]: 79 | try: 80 | if not os.path.isfile(file_path): 81 | return None 82 | 83 | with open(file_path, 'rb') as f: 84 | header = f.read(12) 85 | 86 | for magic, (ext, file_type) in self.magic_numbers.items(): 87 | if header.startswith(magic): 88 | return { 89 | 'extension': ext, 90 | 'file_type': file_type, 91 | 'detected': True 92 | } 93 | 94 | try: 95 | with open(file_path, 'r', encoding='utf-8') as f: 96 | text_header = f.read(100).lower() 97 | if ' Tuple[bool, Optional[Dict[str, str]]]: 117 | file_path_str = str(file_path) 118 | magic_info = self.get_file_magic_info(file_path_str) 119 | 120 | if not magic_info or not magic_info['detected']: 121 | return True, magic_info 122 | 123 | _, actual_ext = os.path.splitext(file_path_str.lower()) 124 | expected_ext = magic_info['extension'] 125 | expected_type = magic_info['file_type'] 126 | 127 | if actual_ext != expected_ext: 128 | logger.info(f"文件 {os.path.basename(file_path_str)} 扩展名为{actual_ext}但实为{expected_type}{expected_ext},允许使用当前扩展名") 129 | 130 | return True, magic_info 131 | 132 | class MediaTypeDetector: 133 | def __init__(self): 134 | pass 135 | 136 | @staticmethod 137 | def is_image(file_path): 138 | if not file_path: 139 | return False 140 | 141 | _, ext = os.path.splitext(file_path.lower()) 142 | return ext in IMAGE_EXTENSIONS 143 | 144 | @staticmethod 145 | def is_video(file_path): 146 | if not file_path: 147 | return False 148 | 149 | _, ext = os.path.splitext(file_path.lower()) 150 | return ext in VIDEO_EXTENSIONS 151 | 152 | @staticmethod 153 | def is_audio(file_path): 154 | if not file_path: 155 | return False 156 | 157 | _, ext = os.path.splitext(file_path.lower()) 158 | return ext in AUDIO_EXTENSIONS 159 | 160 | @staticmethod 161 | def is_media(file_path): 162 | if not file_path: 163 | return False 164 | 165 | _, ext = os.path.splitext(file_path.lower()) 166 | return ext in IMAGE_EXTENSIONS + VIDEO_EXTENSIONS + AUDIO_EXTENSIONS 167 | 168 | def detect_media_type(self, file_path): 169 | if not os.path.isfile(file_path): 170 | return {"valid": False, "type": None} 171 | 172 | _, ext = os.path.splitext(file_path.lower()) 173 | 174 | if ext in IMAGE_EXTENSIONS: 175 | return {"valid": True, "type": "image"} 176 | elif ext in VIDEO_EXTENSIONS: 177 | return {"valid": True, "type": "video"} 178 | elif ext in AUDIO_EXTENSIONS: 179 | return {"valid": True, "type": "audio"} 180 | else: 181 | return {"valid": False, "type": None} 182 | 183 | class GeocodingService: 184 | def __init__(self): 185 | self.internal_dir = get_internal_dir() 186 | self.cache_path = os.path.join(self.internal_dir, "cache_location.json") 187 | self.cookies_path = os.path.join(self.internal_dir, "cookies.json") 188 | self.cache = self._load_cache() 189 | self._clean_cache_if_needed() 190 | 191 | def _clean_cache_if_needed(self, max_entries: int = 1000): 192 | if len(self.cache) > max_entries: 193 | entries_to_keep = dict(list(self.cache.items())[-max_entries:]) 194 | self.cache = entries_to_keep 195 | logger.info(f"缓存过大,已清理至{max_entries}个条目") 196 | self._save_cache() 197 | 198 | def _load_cache(self): 199 | try: 200 | if not os.path.exists(self.cache_path): 201 | return {} 202 | 203 | with open(self.cache_path, 'r', encoding='utf-8') as f: 204 | content = f.read().strip() 205 | if not content: 206 | logger.warning(f"缓存文件 {self.cache_path} 内容为空,删除并重新创建") 207 | try: 208 | os.remove(self.cache_path) 209 | except Exception as e: 210 | logger.error(f"删除空内容缓存文件失败: {str(e)}") 211 | return {} 212 | 213 | try: 214 | return json.loads(content) 215 | except json.JSONDecodeError as e: 216 | logger.error(f"解析缓存文件失败: {str(e)},删除损坏的缓存文件") 217 | try: 218 | os.remove(self.cache_path) 219 | logger.info(f"已删除损坏的缓存文件: {self.cache_path}") 220 | except Exception as remove_error: 221 | logger.error(f"删除损坏的缓存文件失败: {str(remove_error)}") 222 | return {} 223 | except Exception as e: 224 | logger.error(f"加载缓存时出错: {str(e)}") 225 | if os.path.exists(self.cache_path): 226 | try: 227 | os.remove(self.cache_path) 228 | logger.info(f"已删除损坏的缓存文件: {self.cache_path}") 229 | except Exception as remove_error: 230 | logger.error(f"删除损坏的缓存文件失败: {str(remove_error)}") 231 | return {} 232 | 233 | def _save_cache(self): 234 | try: 235 | with open(self.cache_path, 'w', encoding='utf-8') as f: 236 | json.dump(self.cache, f, ensure_ascii=False, indent=2) 237 | except Exception as e: 238 | logger.error(f"保存缓存时出错: {str(e)}") 239 | 240 | def _get_cookies_key(self): 241 | try: 242 | if os.path.exists(self.cookies_path): 243 | try: 244 | with open(self.cookies_path, "r", encoding="utf-8") as f: 245 | saved = json.load(f) 246 | if saved.get("cookies") and saved.get("key"): 247 | return saved["cookies"], saved["key"] 248 | except Exception as e: 249 | logger.error(f"读取cookies失败: {str(e)}") 250 | 251 | target_keys = ['cna', 'passport_login', 'xlly_s', 'HMACCOUNT', 252 | 'Hm_lvt_c8ac07c199b1c09a848aaab761f9f909', 'Hm_lpvt_c8ac07c199b1c09a848aaab761f9f909', 253 | 'tfstk'] 254 | with sync_playwright() as p: 255 | browser = p.chromium.launch(headless=True) 256 | page = browser.new_context().new_page() 257 | page.goto("https://developer.amap.com/demo/javascript-api/example/geocoder/regeocoding") 258 | page.wait_for_timeout(3000) 259 | cookies = {c['name']: c['value'] for c in page.context.cookies() if c['name'] in target_keys} 260 | key = page.get_attribute("#code_origin", "data-jskey") 261 | browser.close() 262 | 263 | try: 264 | with open(self.cookies_path, "w", encoding="utf-8") as f: 265 | json.dump({"cookies": cookies, "key": key}, f, ensure_ascii=False) 266 | except Exception as save_error: 267 | logger.error(f"保存cookies失败: {str(save_error)}") 268 | 269 | return cookies, key 270 | except Exception as e: 271 | logger.error(f"获取cookies和key时出错: {str(e)}") 272 | return None, None 273 | 274 | 275 | 276 | def _get_address_via_api(self, cookies, key, loc, headers, url_tpl): 277 | if not cookies or not key: 278 | return None 279 | try: 280 | url = url_tpl.format(key=key, loc=loc) 281 | resp = requests.get(url, headers=headers, cookies=cookies, timeout=10) 282 | if resp.status_code == 200 and "formatted_address" in resp.text: 283 | try: 284 | json_str = resp.text[resp.text.index('(') + 1:resp.text.rindex(')')] 285 | return json.loads(json_str).get("regeocode", {}).get("formatted_address", "") 286 | except (ValueError, json.JSONDecodeError) as parse_error: 287 | logger.error(f"解析API响应失败: {str(parse_error)}") 288 | return None 289 | else: 290 | logger.warning(f"API响应异常,状态码: {resp.status_code}, 内容: {resp.text[:100]}...") 291 | return None 292 | except requests.exceptions.RequestException as e: 293 | logger.error(f"API请求异常: {str(e)}") 294 | return None 295 | except Exception as e: 296 | logger.error(f"获取地址时发生未知错误: {str(e)}") 297 | return None 298 | 299 | def _get_address_from_api(self, latitude, longitude): 300 | logger.info(f"longitude: {longitude}, latitude: {latitude}") 301 | headers = {'accept': '*/*', 'accept-language': 'zh-CN,zh;q=0.9', 302 | 'referer': 'https://developer.amap.com/demo/javascript-api/example/geocoder/regeocoding', 303 | 'user-agent': 'Mozilla/5.0'} 304 | loc = f"{longitude},{latitude}" 305 | url_tpl = 'https://developer.amap.com/AMapService/v3/geocode/regeo?key={key}&s=rsv3&language=zh_cn&location={loc}&radius=1000&callback=jsonp_765657_&platform=JS&logversion=2.0&appname=https%3A%2F%2Fdeveloper.amap.com%2Fdemo%2Fjavascript-api%2Fexample%2Fgeocoder%2Fregeocoding&csid=123456&sdkversion=1.4.27' 306 | 307 | max_retries = 1 308 | retries = 0 309 | addr = None 310 | 311 | while retries <= max_retries and addr is None: 312 | cookies, key = self._get_cookies_key() 313 | addr = self._get_address_via_api(cookies, key, loc, headers, url_tpl) 314 | if addr is None and retries < max_retries: 315 | logger.info(f"第一次尝试失败,正在重试...") 316 | retries += 1 317 | if self.cookies_path: 318 | try: 319 | os.remove("_internal/cookies.json") 320 | logger.info("已清除cookies缓存,准备重新获取") 321 | except Exception as e: 322 | logger.error(f"清除cookies缓存失败: {str(e)}") 323 | 324 | return addr or "获取失败" 325 | 326 | def get_address(self, longitude, latitude): 327 | cache_key = f"{longitude},{latitude}" 328 | 329 | if cache_key in self.cache: 330 | cached_address = self.cache[cache_key] 331 | return cached_address 332 | 333 | logger.info(f"缓存未命中,从API获取地址: {longitude}, {latitude}") 334 | address = self._get_address_from_api(longitude, latitude) 335 | 336 | if address and address != "获取失败": 337 | self.cache[cache_key] = address 338 | self._save_cache() 339 | 340 | return address 341 | 342 | _resource_manager = ResourceManager() 343 | _media_detector = MediaTypeDetector() 344 | _file_magic_detector = FileMagicNumberDetector() 345 | 346 | def get_resource_path(relative_path): 347 | return _resource_manager.get_resource_path(relative_path) 348 | 349 | def detect_media_type(file_path): 350 | return _media_detector.detect_media_type(file_path) 351 | 352 | def is_media_file(file_path): 353 | return MediaTypeDetector.is_media(file_path) 354 | 355 | def get_address_from_coordinates(longitude, latitude): 356 | service = GeocodingService() 357 | return service.get_address(longitude, latitude) 358 | 359 | def get_common_app_data_path(): 360 | if os.name == 'nt': 361 | app_data_path = os.path.join(os.path.expanduser('~'), 'AppData', 'Local') 362 | return os.path.join(app_data_path, 'LeafSort') 363 | elif os.name == 'posix': 364 | config_path = os.path.join(os.path.expanduser('~'), '.config') 365 | return os.path.join(config_path, 'leafsort') 366 | else: 367 | app_support_path = os.path.join(os.path.expanduser('~'), 'Library', 'Application Support') 368 | return os.path.join(app_support_path, 'LeafSort') 369 | 370 | 371 | def get_internal_dir(): 372 | app_data_path = get_common_app_data_path() 373 | internal_dir = os.path.join(app_data_path, '_internal') 374 | os.makedirs(internal_dir, exist_ok=True) 375 | return internal_dir 376 | 377 | def get_file_magic_info(file_path): 378 | return _file_magic_detector.get_file_magic_info(file_path) 379 | 380 | def verify_file_extension(file_path): 381 | return _file_magic_detector.verify_file_extension(file_path) 382 | 383 | def get_file_type(file_path): 384 | file_path_str = str(file_path) 385 | 386 | magic_info = get_file_magic_info(file_path_str) 387 | if magic_info and magic_info.get('detected', False): 388 | return magic_info.get('file_type', '其他') 389 | 390 | file_ext = os.path.splitext(file_path_str)[1].lower() 391 | 392 | file_type_mapping = { 393 | '图像': ('.jpg', '.jpeg', '.png', '.webp', '.heic', '.bmp', '.gif', '.svg', 394 | '.cr2', '.cr3', '.nef', '.arw', '.orf', '.sr2', '.raf', '.dng', 395 | '.tiff', '.tif', '.psd', '.rw2', '.pef', '.nrw'), 396 | '视频': ('.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv', '.m4v', '.webm'), 397 | '音乐': ('.mp3', '.wav', '.flac', '.aac', '.ogg', '.wma', '.m4a'), 398 | '文档': ('.pdf', '.doc', '.docx', '.txt', '.rtf', '.xls', '.xlsx', '.ppt', '.pptx', 399 | '.csv', '.html', '.htm', '.xml', '.epub', '.md', '.log'), 400 | '压缩包': ('.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.iso', '.jar') 401 | } 402 | 403 | for file_type, extensions in file_type_mapping.items(): 404 | if file_ext in extensions: 405 | return file_type 406 | 407 | if os.path.isfile(file_path_str): 408 | try: 409 | result = detect_media_type(file_path_str) 410 | if result.get('valid', False): 411 | media_type = result.get('type') 412 | mime_to_category = { 413 | 'image': '图像', 414 | 'video': '视频', 415 | 'audio': '音乐' 416 | } 417 | if media_type in mime_to_category: 418 | return mime_to_category[media_type] 419 | except (FileNotFoundError, IOError): 420 | pass 421 | 422 | return '其他' --------------------------------------------------------------------------------