model_train_dm/backend/image_server.py

347 lines
14 KiB
Python
Raw Permalink Normal View History

2026-07-27 17:51:49 +08:00
"""
独立图片预览 HTTP 服务
启动方式: python image_server.py [端口]
默认端口: 8080
"""
import os
import sys
import json
import mimetypes
import http.server
from pathlib import Path
from urllib.parse import unquote, urlparse, parse_qs
# 图片根目录(与 settings/base.py 中 FILESPACE_ROOT_PATH 默认值一致)
IMAGE_ROOT = Path(r"D:\00_Workspace\python\model_train\project_space")
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff", ".tif"}
BASE_DIR = Path(__file__).resolve().parent
TEMPLATE = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>图片预览 - {title}</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, "Microsoft YaHei", sans-serif; background: #f5f5f5; }}
.header {{ background: #1a1a2e; color: #fff; padding: 16px 24px; display: flex; align-items: center; gap: 16px; }}
.header h1 {{ font-size: 18px; font-weight: 500; }}
.header a {{ color: #a0a0ff; text-decoration: none; }}
.header a:hover {{ color: #fff; }}
.breadcrumb {{ color: #aaa; font-size: 14px; }}
.toolbar {{ background: #fff; padding: 12px 24px; border-bottom: 1px solid #e0e0e0; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }}
.toolbar button {{ padding: 6px 16px; border: 1px solid #d0d0d0; border-radius: 4px; background: #fff; cursor: pointer; font-size: 13px; }}
.toolbar button:hover {{ background: #f0f0f0; }}
.toolbar button.active {{ background: #1a1a2e; color: #fff; border-color: #1a1a2e; }}
.content {{ padding: 16px 24px; }}
.folders {{ display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 16px; }}
.folder-card {{ background: #fff; border: 1px solid #e0e0e0; border-radius: 6px; padding: 20px 24px; cursor: pointer; min-width: 180px; display: flex; align-items: center; gap: 10px; }}
.folder-card:hover {{ border-color: #1a1a2e; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
.folder-card .icon {{ font-size: 28px; }}
.folder-card .name {{ font-size: 14px; word-break: break-all; }}
.gallery {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }}
.card {{ background: #fff; border: 1px solid #e0e0e0; border-radius: 6px; overflow: hidden; cursor: pointer; }}
.card:hover {{ border-color: #1a1a2e; box-shadow: 0 2px 12px rgba(0,0,0,0.1); }}
.card img {{ width: 100%; height: 160px; object-fit: cover; display: block; }}
.card .info {{ padding: 8px 12px; font-size: 12px; color: #666; }}
.card .info .fname {{ font-size: 13px; color: #333; margin-bottom: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
.empty {{ text-align: center; color: #999; padding: 60px; font-size: 15px; }}
/* lightbox */
.lightbox {{ display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.92); z-index: 9999; justify-content: center; align-items: center; }}
.lightbox.show {{ display: flex; }}
.lightbox img {{ max-width: 92%; max-height: 92%; object-fit: contain; }}
.lightbox .close {{ position: absolute; top: 20px; right: 30px; color: #fff; font-size: 36px; cursor: pointer; }}
.lightbox .nav {{ position: absolute; top: 50%; color: #fff; font-size: 48px; cursor: pointer; user-select: none; padding: 20px; }}
.lightbox .prev {{ left: 10px; }}
.lightbox .next {{ right: 10px; }}
.lightbox .info-bar {{ position: absolute; bottom: 20px; color: #ccc; font-size: 13px; text-align: center; width: 100%; }}
footer {{ text-align: center; padding: 20px; color: #999; font-size: 12px; }}
</style>
</head>
<body>
<div class="header">
<h1>图片预览服务</h1>
<span class="breadcrumb">{breadcrumb}</span>
</div>
<div class="toolbar">
<a href="?{dir_param}view=gallery"><button class="{gallery_active}">网格</button></a>
<a href="?{dir_param}view=list"><button class="{list_active}">列表</button></a>
</div>
<div class="content">
{body}
</div>
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
<span class="close">&times;</span>
<span class="nav prev" onclick="event.stopPropagation();navigate(-1)">&lsaquo;</span>
<img id="lightbox-img" src="" onclick="event.stopPropagation()">
<span class="nav next" onclick="event.stopPropagation();navigate(1)">&rsaquo;</span>
<div class="info-bar" id="lightbox-info"></div>
</div>
<footer>独立图片预览服务 &mdash; 端口 {port}</footer>
<script>
let imageList = {image_list};
let currentIndex = 0;
function openLightbox(src, idx) {{
currentIndex = idx;
document.getElementById('lightbox-img').src = src;
document.getElementById('lightbox').classList.add('show');
updateInfo();
}}
function closeLightbox() {{ document.getElementById('lightbox').classList.remove('show'); }}
function navigate(dir) {{
currentIndex = (currentIndex + dir + imageList.length) % imageList.length;
document.getElementById('lightbox-img').src = '/file/' + imageList[currentIndex].path;
updateInfo();
}}
function updateInfo() {{
document.getElementById('lightbox-info').textContent =
(currentIndex+1) + ' / ' + imageList.length + ' - ' + imageList[currentIndex].name;
}}
document.addEventListener('keydown', function(e) {{
if (document.getElementById('lightbox').classList.contains('show')) {{
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowLeft') navigate(-1);
if (e.key === 'ArrowRight') navigate(1);
}}
}});
</script>
</body>
</html>
"""
def is_image(filename: str) -> bool:
return Path(filename).suffix.lower() in IMAGE_EXTENSIONS
def get_breadcrumb_html(relative_path: str) -> str:
"""生成面包屑导航"""
if not relative_path or relative_path == ".":
return '<a href="/">根目录</a>'
parts = relative_path.replace("\\", "/").strip("/").split("/")
links = ['<a href="/">根目录</a>']
accumulated = ""
for p in parts:
accumulated += "/" + p
links.append(f'<a href="/?dir={accumulated.lstrip("/")}">{p}</a>')
return " / ".join(links)
def get_parent_dir(relative_path: str) -> str:
if not relative_path or relative_path == ".":
return ""
parent = str(Path(relative_path).parent)
return "" if parent == "." else parent
def build_page_html(current_dir: str, view_mode: str, port: int) -> str:
root = IMAGE_ROOT
if current_dir:
scan_path = root / current_dir
else:
scan_path = root
dir_param = f"dir={current_dir}&" if current_dir else ""
if not scan_path.exists():
body = f'<div class="empty">目录不存在: {scan_path}</div>'
return TEMPLATE.format(
title="目录不存在",
breadcrumb=get_breadcrumb_html(current_dir),
dir_param=dir_param,
body=body,
current_view=view_mode,
gallery_active="active" if view_mode != "list" else "",
list_active="active" if view_mode == "list" else "",
image_list="[]",
port=port,
)
folders = []
images = []
try:
for entry in sorted(scan_path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
if entry.name.startswith("."):
continue
if entry.is_dir():
folders.append(entry.name)
elif is_image(entry.name):
images.append(entry.name)
except PermissionError:
pass
body_parts = []
# 返回上级目录
if current_dir and current_dir != ".":
parent = get_parent_dir(current_dir)
body_parts.append(
f'<div class="folders"><a href="/?dir={parent}" class="folder-card">'
f'<span class="icon">📁</span><span class="name">../</span></a></div>'
)
# 子目录
if folders:
parts = []
for f in folders:
sub_dir = f"{current_dir}/{f}".lstrip("/") if current_dir else f
parts.append(
f'<a href="/?dir={sub_dir}" class="folder-card">'
f'<span class="icon">📁</span><span class="name">{f}</span></a>'
)
body_parts.append(f'<div class="folders">{"".join(parts)}</div>')
# 图片
if images:
image_list_data = []
for img in images:
if current_dir:
rel = f"{current_dir}/{img}".replace("\\", "/")
else:
rel = img
image_list_data.append({"name": img, "path": rel})
if view_mode == "list":
rows = []
for i, img_info in enumerate(image_list_data):
rows.append(
f'<tr><td>{i+1}</td>'
f'<td><a href="/file/{img_info["path"]}" target="_blank">{img_info["name"]}</a></td>'
f'<td><a href="javascript:openLightbox(\'/file/{img_info["path"]}\',{i})">预览</a></td></tr>'
)
body_parts.append(
'<table style="width:100%;border-collapse:collapse"><thead><tr>'
'<th style="text-align:left;padding:8px">#</th>'
'<th style="text-align:left;padding:8px">文件名</th>'
'<th style="text-align:left;padding:8px">操作</th>'
'</tr></thead><tbody>' + "".join(rows) + "</tbody></table>"
)
else:
cards = []
for i, img_info in enumerate(image_list_data):
cards.append(
f'<div class="card" onclick="openLightbox(\'/file/{img_info["path"]}\',{i})">'
f'<img src="/file/{img_info["path"]}" loading="lazy">'
f'<div class="info"><div class="fname">{img_info["name"]}</div></div>'
f'</div>'
)
body_parts.append(f'<div class="gallery">{"".join(cards)}</div>')
body = "".join(body_parts)
return TEMPLATE.format(
title=current_dir or "根目录",
breadcrumb=get_breadcrumb_html(current_dir),
dir_param=dir_param,
body=body,
current_view=view_mode,
gallery_active="active" if view_mode != "list" else "",
list_active="active" if view_mode == "list" else "",
image_list=json.dumps(image_list_data),
port=port,
)
else:
if folders:
body = "".join(body_parts)
else:
body_parts.append('<div class="empty">此目录下没有图片</div>')
body = "".join(body_parts)
return TEMPLATE.format(
title=current_dir or "根目录",
breadcrumb=get_breadcrumb_html(current_dir),
dir_param=dir_param,
body=body,
current_view=view_mode,
gallery_active="active" if view_mode != "list" else "",
list_active="active" if view_mode == "list" else "",
image_list="[]",
port=port,
)
class ImagePreviewHandler(http.server.SimpleHTTPRequestHandler):
"""自定义请求处理器"""
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(BASE_DIR), **kwargs)
def do_GET(self):
parsed = urlparse(self.path)
path = unquote(parsed.path)
params = parse_qs(parsed.query)
# 根路径 → HTML 页面
if path == "/":
current_dir = params.get("dir", [""])[0]
view_mode = params.get("view", ["gallery"])[0]
html = build_page_html(current_dir, view_mode, PORT)
self._respond_html(html)
return
# /file/ 路径 → 返回实际图片文件
if path.startswith("/file/"):
file_rel = path[len("/file/"):]
file_path = IMAGE_ROOT / file_rel
if file_path.exists() and file_path.is_file():
self._serve_file(file_path)
else:
self.send_error(404, f"File not found: {file_rel}")
return
# 其他路径 → 默认静态文件处理
super().do_GET()
def _respond_html(self, html: str):
data = html.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(data)
def _serve_file(self, file_path: Path):
mime_type, _ = mimetypes.guess_type(str(file_path))
if mime_type is None:
mime_type = "application/octet-stream"
file_size = file_path.stat().st_size
self.send_response(200)
self.send_header("Content-Type", mime_type)
self.send_header("Content-Length", str(file_size))
self.send_header("Cache-Control", "public, max-age=3600")
self.end_headers()
with open(file_path, "rb") as f:
self.wfile.write(f.read())
def log_message(self, format, *args):
print(f"[{self.log_date_time_string()}] {args[0]}")
def main():
global PORT
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
# 确保图片根目录存在
if not IMAGE_ROOT.exists():
os.makedirs(IMAGE_ROOT, exist_ok=True)
print(f"已创建目录: {IMAGE_ROOT}")
server = http.server.HTTPServer(("0.0.0.0", PORT), ImagePreviewHandler)
print(f"=" * 55)
print(f" 图片预览服务已启动")
print(f" 图片根目录: {IMAGE_ROOT}")
print(f" 访问地址: http://localhost:{PORT}")
print(f" 按 Ctrl+C 停止服务")
print(f"=" * 55)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n服务已停止")
server.server_close()
if __name__ == "__main__":
main()