整合去雾网页工具
This commit is contained in:
13
web_dehaze/README.md
Normal file
13
web_dehaze/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Dehaze Web Console
|
||||
|
||||
本地网页端入口,用于选择 `待去雾图片/` 中的图片,运行 AOD、Baidu_API、DCP、DehazeNet、GCANet、RefineDNet,并统一展示结果与后处理结果。
|
||||
|
||||
```bash
|
||||
bash run_dehaze_web.sh
|
||||
```
|
||||
|
||||
结果统一保存到 `web_results/`。当前环境缺少 `caffe` 或 `torch` 时,AOD/DehazeNet/GCANet/RefineDNet 会在运行时显示缺少依赖;DCP 和后处理可直接使用当前 Python 环境运行。
|
||||
|
||||
当前默认调度:
|
||||
- AOD、DehazeNet:`/home/wkmgc/miniconda3/envs/dehaze_caffe/bin/python`
|
||||
- GCANet、RefineDNet:`/home/wkmgc/miniconda3/envs/seg_server/bin/python`
|
||||
501
web_dehaze/pipeline.py
Normal file
501
web_dehaze/pipeline.py
Normal file
@@ -0,0 +1,501 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from postprocess import POSTPROCESSORS, run_postprocess
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
IMAGE_DIR = ROOT / "待去雾图片"
|
||||
RESULTS_DIR = ROOT / "web_results"
|
||||
CONDA_ROOT = Path(os.environ.get("CONDA_EXE", sys.executable)).resolve().parents[1]
|
||||
DEFAULT_CAFFE_PYTHON = CONDA_ROOT / "envs" / "dehaze_caffe" / "bin" / "python"
|
||||
DEFAULT_TORCH_PYTHON = CONDA_ROOT / "envs" / "seg_server" / "bin" / "python"
|
||||
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp"}
|
||||
DEHAZE_METHODS: dict[str, dict[str, Any]] = {
|
||||
"AOD": {
|
||||
"label": "AOD",
|
||||
"module": "caffe",
|
||||
"python_group": "caffe",
|
||||
"model_file": ROOT / "AOD-Net_最好加入后处理" / "AOD_Net.caffemodel",
|
||||
},
|
||||
"Baidu_API": {"label": "Baidu_API", "module": "requests", "python_group": "server", "model_file": None},
|
||||
"DCP": {"label": "DCP", "module": "cv2", "python_group": "server", "model_file": None},
|
||||
"DehazeNet": {
|
||||
"label": "DehazeNet",
|
||||
"module": "caffe",
|
||||
"python_group": "caffe",
|
||||
"model_file": ROOT / "DehazeNet" / "DehazeNet.caffemodel",
|
||||
},
|
||||
"GCANet": {
|
||||
"label": "GCANet",
|
||||
"module": "torch",
|
||||
"python_group": "torch",
|
||||
"model_file": ROOT / "GCANet" / "models" / "wacv_gcanet_dehaze.pth",
|
||||
},
|
||||
"RefineDNet": {
|
||||
"label": "RefineDNet",
|
||||
"module": "torch",
|
||||
"python_group": "torch",
|
||||
"model_file": ROOT / "RefineDNet" / "checkpoints" / "refined_DCP_outdoor" / "60_net_Refiner_J.pth",
|
||||
},
|
||||
}
|
||||
|
||||
LogFn = Callable[[str], None]
|
||||
|
||||
|
||||
def _noop_log(_: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _safe_slug(value: str) -> str:
|
||||
slug = re.sub(r"[^A-Za-z0-9_.-]+", "_", value).strip("._-")
|
||||
return slug or "image"
|
||||
|
||||
|
||||
def image_id(filename: str) -> str:
|
||||
stem = Path(filename).stem
|
||||
digest = hashlib.sha1(filename.encode("utf-8")).hexdigest()[:8]
|
||||
return f"{_safe_slug(stem)}_{digest}"
|
||||
|
||||
|
||||
def source_image_path(filename: str) -> Path:
|
||||
if Path(filename).name != filename:
|
||||
raise ValueError("Invalid image filename")
|
||||
path = IMAGE_DIR / filename
|
||||
if not path.exists() or path.suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
raise FileNotFoundError(filename)
|
||||
return path
|
||||
|
||||
|
||||
def image_result_dir(filename: str) -> Path:
|
||||
return RESULTS_DIR / image_id(filename)
|
||||
|
||||
|
||||
def dehaze_result_path(filename: str, method: str) -> Path:
|
||||
return image_result_dir(filename) / "dehaze" / f"{_safe_slug(method)}.png"
|
||||
|
||||
|
||||
def post_result_path(filename: str, source: str, processor: str, params: dict[str, Any] | None = None) -> Path:
|
||||
params = params or {}
|
||||
suffix = ""
|
||||
if processor == "manual_sv":
|
||||
suffix = f"_S{int(float(params.get('s_gain', 1.0)) * 100)}_V{int(float(params.get('v_gain', 1.0)) * 100)}"
|
||||
if params.get("match_hue"):
|
||||
suffix += "_H"
|
||||
return image_result_dir(filename) / "postprocess" / f"{_safe_slug(source)}__{_safe_slug(processor)}{suffix}.png"
|
||||
|
||||
|
||||
def relpath(path: Path) -> str:
|
||||
return path.resolve().relative_to(ROOT).as_posix()
|
||||
|
||||
|
||||
def python_for_group(group: str | None) -> Path:
|
||||
if group == "caffe":
|
||||
return Path(os.environ.get("DEHAZE_CAFFE_PYTHON", DEFAULT_CAFFE_PYTHON))
|
||||
if group == "torch":
|
||||
return Path(os.environ.get("DEHAZE_TORCH_PYTHON", DEFAULT_TORCH_PYTHON))
|
||||
return Path(sys.executable)
|
||||
|
||||
|
||||
def python_for_method(method: str) -> Path:
|
||||
return python_for_group(DEHAZE_METHODS[method].get("python_group"))
|
||||
|
||||
|
||||
def list_images() -> list[dict[str, Any]]:
|
||||
images: list[dict[str, Any]] = []
|
||||
if not IMAGE_DIR.exists():
|
||||
return images
|
||||
for path in sorted(IMAGE_DIR.iterdir(), key=lambda p: p.name.lower()):
|
||||
if not path.is_file() or path.suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
continue
|
||||
width = height = None
|
||||
mode = ""
|
||||
try:
|
||||
with Image.open(path) as img:
|
||||
width, height = img.size
|
||||
mode = img.mode
|
||||
except Exception:
|
||||
pass
|
||||
images.append(
|
||||
{
|
||||
"name": path.name,
|
||||
"id": image_id(path.name),
|
||||
"size": path.stat().st_size,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"mode": mode,
|
||||
"path": relpath(path),
|
||||
}
|
||||
)
|
||||
return images
|
||||
|
||||
|
||||
def module_available(module_name: str, python_path: Path | None = None) -> bool:
|
||||
python_path = python_path or Path(sys.executable)
|
||||
if python_path.resolve() == Path(sys.executable).resolve():
|
||||
return importlib.util.find_spec(module_name) is not None
|
||||
if not python_path.exists():
|
||||
return False
|
||||
result = subprocess.run(
|
||||
[str(python_path), "-c", f"import {module_name}"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=20,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def capabilities() -> dict[str, Any]:
|
||||
methods = {}
|
||||
for key, info in DEHAZE_METHODS.items():
|
||||
module_name = info.get("module")
|
||||
model_file = info.get("model_file")
|
||||
python_path = python_for_method(key)
|
||||
python_ok = python_path.exists()
|
||||
module_ok = bool(module_available(module_name, python_path)) if module_name and python_ok else False
|
||||
model_ok = bool(model_file.exists()) if model_file else True
|
||||
methods[key] = {
|
||||
"label": info["label"],
|
||||
"available": python_ok and module_ok and model_ok,
|
||||
"module": module_name,
|
||||
"python": str(python_path),
|
||||
"python_ok": python_ok,
|
||||
"module_ok": module_ok,
|
||||
"model_ok": model_ok,
|
||||
"model_file": relpath(model_file) if model_file else "",
|
||||
}
|
||||
return {
|
||||
"python": sys.executable,
|
||||
"image_dir": relpath(IMAGE_DIR),
|
||||
"results_dir": relpath(RESULTS_DIR),
|
||||
"methods": methods,
|
||||
"postprocessors": POSTPROCESSORS,
|
||||
}
|
||||
|
||||
|
||||
def get_results(filename: str) -> dict[str, Any]:
|
||||
source = source_image_path(filename)
|
||||
dehaze = []
|
||||
for method, info in DEHAZE_METHODS.items():
|
||||
path = dehaze_result_path(filename, method)
|
||||
dehaze.append(
|
||||
{
|
||||
"method": method,
|
||||
"label": info["label"],
|
||||
"exists": path.exists(),
|
||||
"path": relpath(path) if path.exists() else "",
|
||||
}
|
||||
)
|
||||
|
||||
post = []
|
||||
post_dir = image_result_dir(filename) / "postprocess"
|
||||
if post_dir.exists():
|
||||
for path in sorted(post_dir.glob("*.png"), key=lambda p: p.name.lower()):
|
||||
post.append({"name": path.stem, "exists": True, "path": relpath(path)})
|
||||
|
||||
return {
|
||||
"image": filename,
|
||||
"original": {"label": "原图", "exists": True, "path": relpath(source)},
|
||||
"dehaze": dehaze,
|
||||
"postprocess": post,
|
||||
}
|
||||
|
||||
|
||||
def _reset_dir(path: Path) -> None:
|
||||
if path.exists():
|
||||
shutil.rmtree(path)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _prepare_rgb_png(source: Path, target_dir: Path, min_side: int | None = None) -> Path:
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
image = Image.open(source).convert("RGB")
|
||||
if min_side and min(image.size) < min_side:
|
||||
scale = min_side / float(min(image.size))
|
||||
new_size = (max(1, int(round(image.size[0] * scale))), max(1, int(round(image.size[1] * scale))))
|
||||
image = image.resize(new_size, Image.BICUBIC)
|
||||
target = target_dir / f"{_safe_slug(source.stem)}.png"
|
||||
image.save(target)
|
||||
return target
|
||||
|
||||
|
||||
def _copy_final_image(source: Path, destination: Path, original: Path, force_original_size: bool = True) -> None:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not force_original_size:
|
||||
shutil.copy2(source, destination)
|
||||
return
|
||||
original_size = Image.open(original).size
|
||||
image = Image.open(source).convert("RGB")
|
||||
if image.size != original_size:
|
||||
image = image.resize(original_size, Image.BICUBIC)
|
||||
image.save(destination)
|
||||
|
||||
|
||||
def _run_command(command: list[str], cwd: Path, log: LogFn, timeout: int | None = None) -> None:
|
||||
log(f"$ {' '.join(command)}")
|
||||
env = os.environ.copy()
|
||||
env.setdefault("GLOG_minloglevel", "2")
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=str(cwd),
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
start = time.time()
|
||||
assert process.stdout is not None
|
||||
for line in process.stdout:
|
||||
log(line.rstrip())
|
||||
if timeout and time.time() - start > timeout:
|
||||
process.kill()
|
||||
raise TimeoutError(f"Command timed out after {timeout}s")
|
||||
return_code = process.wait()
|
||||
if return_code != 0:
|
||||
raise RuntimeError(f"Command failed with code {return_code}")
|
||||
|
||||
|
||||
def _require_module(module_name: str, python_path: Path | None = None) -> None:
|
||||
python_path = python_path or Path(sys.executable)
|
||||
if not module_available(module_name, python_path):
|
||||
raise RuntimeError(f"Python 环境 {python_path} 缺少模块:{module_name}")
|
||||
|
||||
|
||||
def run_dehaze_method(filename: str, method: str, options: dict[str, Any] | None = None, log: LogFn | None = None) -> Path:
|
||||
options = options or {}
|
||||
log = log or _noop_log
|
||||
if method not in DEHAZE_METHODS:
|
||||
raise ValueError(f"Unknown method: {method}")
|
||||
|
||||
source = source_image_path(filename)
|
||||
log(f"开始 {method}: {filename}")
|
||||
|
||||
runners = {
|
||||
"DCP": _run_dcp,
|
||||
"Baidu_API": _run_baidu,
|
||||
"AOD": _run_aod,
|
||||
"DehazeNet": _run_dehazenet,
|
||||
"GCANet": _run_gcanet,
|
||||
"RefineDNet": _run_refinednet,
|
||||
}
|
||||
result = runners[method](source, filename, options, log)
|
||||
log(f"完成 {method}: {relpath(result)}")
|
||||
return result
|
||||
|
||||
|
||||
def _run_dcp(source: Path, filename: str, options: dict[str, Any], log: LogFn) -> Path:
|
||||
_require_module("cv2")
|
||||
work = image_result_dir(filename) / "work" / "DCP"
|
||||
_reset_dir(work)
|
||||
src_dir = work / "src"
|
||||
(work / "dark").mkdir(parents=True, exist_ok=True)
|
||||
(work / "trans").mkdir(parents=True, exist_ok=True)
|
||||
(work / "result").mkdir(parents=True, exist_ok=True)
|
||||
input_copy = _prepare_rgb_png(source, src_dir)
|
||||
sz = int(options.get("sz", 10))
|
||||
tx = float(options.get("tx", 0.2))
|
||||
_run_command([sys.executable, "dehaze.py", str(work), str(sz), str(tx)], ROOT / "DCP_最好加入后处理", log)
|
||||
generated = work / "result" / f"{input_copy.stem}_{sz}_{tx}_result.png"
|
||||
if not generated.exists():
|
||||
matches = sorted((work / "result").glob("*_result.png"))
|
||||
if not matches:
|
||||
raise FileNotFoundError("DCP did not create a result image")
|
||||
generated = matches[-1]
|
||||
output = dehaze_result_path(filename, "DCP")
|
||||
_copy_final_image(generated, output, source)
|
||||
return output
|
||||
|
||||
|
||||
def _run_baidu(source: Path, filename: str, options: dict[str, Any], log: LogFn) -> Path:
|
||||
_require_module("requests")
|
||||
script_path = ROOT / "Baidu_API_最好加入后处理" / "1_Baidu_Dehaze.py"
|
||||
spec = importlib.util.spec_from_file_location("baidu_dehaze_script", script_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("无法加载 Baidu API 脚本")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
if os.environ.get("BAIDU_API_KEY"):
|
||||
module.API_KEY = os.environ["BAIDU_API_KEY"]
|
||||
if os.environ.get("BAIDU_SECRET_KEY"):
|
||||
module.SECRET_KEY = os.environ["BAIDU_SECRET_KEY"]
|
||||
|
||||
token = module.get_access_token()
|
||||
if not token:
|
||||
raise RuntimeError("Baidu access token 获取失败")
|
||||
log("Baidu access token 获取成功")
|
||||
processed = module.process_image(str(source), token)
|
||||
if not processed:
|
||||
raise RuntimeError("Baidu API 未返回图像")
|
||||
|
||||
work = image_result_dir(filename) / "work" / "Baidu_API"
|
||||
_reset_dir(work)
|
||||
tmp = work / "baidu_result.png"
|
||||
tmp.write_bytes(processed)
|
||||
output = dehaze_result_path(filename, "Baidu_API")
|
||||
_copy_final_image(tmp, output, source)
|
||||
return output
|
||||
|
||||
|
||||
def _run_aod(source: Path, filename: str, options: dict[str, Any], log: LogFn) -> Path:
|
||||
python_path = python_for_method("AOD")
|
||||
_require_module("caffe", python_path)
|
||||
work = image_result_dir(filename) / "work" / "AOD"
|
||||
_reset_dir(work)
|
||||
input_dir = work / "input"
|
||||
output_dir = work / "output"
|
||||
input_copy = _prepare_rgb_png(source, input_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
_run_command([str(python_path), "test/test.py", str(input_dir), str(output_dir)], ROOT / "AOD-Net_最好加入后处理", log)
|
||||
generated = output_dir / f"{input_copy.stem}_AOD-Net.png"
|
||||
if not generated.exists():
|
||||
matches = sorted(output_dir.glob("*.png"))
|
||||
if not matches:
|
||||
raise FileNotFoundError("AOD did not create a result image")
|
||||
generated = matches[-1]
|
||||
output = dehaze_result_path(filename, "AOD")
|
||||
_copy_final_image(generated, output, source)
|
||||
return output
|
||||
|
||||
|
||||
def _run_dehazenet(source: Path, filename: str, options: dict[str, Any], log: LogFn) -> Path:
|
||||
python_path = python_for_method("DehazeNet")
|
||||
_require_module("caffe", python_path)
|
||||
work = image_result_dir(filename) / "work" / "DehazeNet" / "img"
|
||||
_reset_dir(work)
|
||||
input_copy = _prepare_rgb_png(source, work / "src")
|
||||
_run_command([str(python_path), "DehazeNet.py", str(work)], ROOT / "DehazeNet", log)
|
||||
generated = work / "result" / f"{input_copy.stem}_result.png"
|
||||
if not generated.exists():
|
||||
matches = sorted((work / "result").glob("*_result.png"))
|
||||
if not matches:
|
||||
raise FileNotFoundError("DehazeNet did not create a result image")
|
||||
generated = matches[-1]
|
||||
output = dehaze_result_path(filename, "DehazeNet")
|
||||
_copy_final_image(generated, output, source)
|
||||
return output
|
||||
|
||||
|
||||
def _run_gcanet(source: Path, filename: str, options: dict[str, Any], log: LogFn) -> Path:
|
||||
python_path = python_for_method("GCANet")
|
||||
_require_module("torch", python_path)
|
||||
work = image_result_dir(filename) / "work" / "GCANet"
|
||||
_reset_dir(work)
|
||||
input_dir = work / "input"
|
||||
output_dir = work / "output"
|
||||
input_copy = _prepare_rgb_png(source, input_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
_run_command(
|
||||
[str(python_path), "test.py", "--task", "dehaze", "--gpu_id", "0", "--indir", str(input_dir), "--outdir", str(output_dir)],
|
||||
ROOT / "GCANet",
|
||||
log,
|
||||
)
|
||||
generated = output_dir / f"{input_copy.stem}_dehaze.png"
|
||||
if not generated.exists():
|
||||
matches = sorted(output_dir.glob("*.png"))
|
||||
if not matches:
|
||||
raise FileNotFoundError("GCANet did not create a result image")
|
||||
generated = matches[-1]
|
||||
output = dehaze_result_path(filename, "GCANet")
|
||||
_copy_final_image(generated, output, source)
|
||||
return output
|
||||
|
||||
|
||||
def _run_refinednet(source: Path, filename: str, options: dict[str, Any], log: LogFn) -> Path:
|
||||
python_path = python_for_method("RefineDNet")
|
||||
_require_module("torch", python_path)
|
||||
_require_module("torchvision", python_path)
|
||||
work = image_result_dir(filename) / "work" / "RefineDNet"
|
||||
_reset_dir(work)
|
||||
dataroot = work / "dataset"
|
||||
input_copy = _prepare_rgb_png(source, dataroot / "test", min_side=256)
|
||||
method_name = "RefineDNet"
|
||||
_run_command(
|
||||
[
|
||||
str(python_path),
|
||||
"quick_test.py",
|
||||
"--dataroot",
|
||||
str(dataroot),
|
||||
"--dataset_mode",
|
||||
"single",
|
||||
"--name",
|
||||
"refined_DCP_outdoor",
|
||||
"--model",
|
||||
"refined_DCP",
|
||||
"--phase",
|
||||
"test",
|
||||
"--preprocess",
|
||||
"none",
|
||||
"--save_image",
|
||||
"--method_name",
|
||||
method_name,
|
||||
"--epoch",
|
||||
"60",
|
||||
"--gpu_ids",
|
||||
"0",
|
||||
],
|
||||
ROOT / "RefineDNet",
|
||||
log,
|
||||
)
|
||||
generated = dataroot / method_name / f"{input_copy.stem}_dehz.png"
|
||||
if not generated.exists():
|
||||
matches = sorted((dataroot / method_name).glob("*.png"))
|
||||
if not matches:
|
||||
raise FileNotFoundError("RefineDNet did not create a result image")
|
||||
generated = matches[-1]
|
||||
output = dehaze_result_path(filename, "RefineDNet")
|
||||
_copy_final_image(generated, output, source)
|
||||
return output
|
||||
|
||||
|
||||
def run_postprocessors_for_source(
|
||||
filename: str,
|
||||
source_name: str,
|
||||
processors: list[str],
|
||||
params: dict[str, Any] | None = None,
|
||||
reference_filename: str | None = None,
|
||||
log: LogFn | None = None,
|
||||
) -> list[Path]:
|
||||
params = params or {}
|
||||
log = log or _noop_log
|
||||
if source_name == "original":
|
||||
source_path = source_image_path(filename)
|
||||
else:
|
||||
source_path = dehaze_result_path(filename, source_name)
|
||||
if not source_path.exists():
|
||||
raise FileNotFoundError(f"后处理源图不存在:{source_name}")
|
||||
|
||||
reference_path = source_image_path(reference_filename or filename)
|
||||
outputs: list[Path] = []
|
||||
for processor in processors:
|
||||
proc_params = params.get(processor, params)
|
||||
output = post_result_path(filename, source_name, processor, proc_params)
|
||||
meta = run_postprocess(processor, source_path, output, reference_path=reference_path, params=proc_params)
|
||||
outputs.append(output)
|
||||
log(f"后处理完成 {source_name} / {processor}: {json.dumps(meta, ensure_ascii=False)}")
|
||||
return outputs
|
||||
|
||||
|
||||
def resolve_asset(relative_path: str) -> Path:
|
||||
target = (ROOT / relative_path).resolve()
|
||||
allowed_roots = [IMAGE_DIR.resolve(), RESULTS_DIR.resolve()]
|
||||
if not any(target == root or root in target.parents for root in allowed_roots):
|
||||
raise PermissionError("Asset path is outside allowed roots")
|
||||
if not target.exists() or not target.is_file():
|
||||
raise FileNotFoundError(relative_path)
|
||||
return target
|
||||
164
web_dehaze/postprocess.py
Normal file
164
web_dehaze/postprocess.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from scipy.optimize import minimize
|
||||
from skimage import color, exposure
|
||||
|
||||
|
||||
POSTPROCESSORS: dict[str, dict[str, Any]] = {
|
||||
"manual_sv": {
|
||||
"label": "手动 S/V",
|
||||
"needs_reference": False,
|
||||
"params": {"s_gain": 1.0, "v_gain": 1.0},
|
||||
},
|
||||
"hsv_hist": {
|
||||
"label": "HSV 直方图匹配",
|
||||
"needs_reference": True,
|
||||
"params": {"match_hue": False},
|
||||
},
|
||||
"auto_sv": {
|
||||
"label": "自动 S/V",
|
||||
"needs_reference": True,
|
||||
"params": {},
|
||||
},
|
||||
"hist_auto_sv": {
|
||||
"label": "直方图 + 自动 S/V",
|
||||
"needs_reference": True,
|
||||
"params": {"match_hue": False},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _load_rgb_float(path: Path) -> tuple[np.ndarray, tuple[int, int]]:
|
||||
image = Image.open(path).convert("RGB")
|
||||
return np.asarray(image, dtype=np.float64) / 255.0, image.size
|
||||
|
||||
|
||||
def _load_reference_float(path: Path, size: tuple[int, int]) -> np.ndarray:
|
||||
image = Image.open(path).convert("RGB")
|
||||
if image.size != size:
|
||||
image = image.resize(size, Image.BILINEAR)
|
||||
return np.asarray(image, dtype=np.float64) / 255.0
|
||||
|
||||
|
||||
def _save_rgb_float(array: np.ndarray, output_path: Path) -> None:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
image = np.clip(array, 0.0, 1.0)
|
||||
Image.fromarray((image * 255.0 + 0.5).astype(np.uint8)).save(output_path)
|
||||
|
||||
|
||||
def adjust_sv(source_path: Path, output_path: Path, s_gain: float = 1.0, v_gain: float = 1.0) -> dict[str, Any]:
|
||||
src_rgb, _ = _load_rgb_float(source_path)
|
||||
hsv = color.rgb2hsv(src_rgb)
|
||||
hsv[:, :, 1] = np.clip(hsv[:, :, 1] * float(s_gain), 0.0, 1.0)
|
||||
hsv[:, :, 2] = np.clip(hsv[:, :, 2] * float(v_gain), 0.0, 1.0)
|
||||
_save_rgb_float(color.hsv2rgb(hsv), output_path)
|
||||
return {"s_gain": float(s_gain), "v_gain": float(v_gain)}
|
||||
|
||||
|
||||
def hsv_hist_match(
|
||||
source_path: Path,
|
||||
reference_path: Path,
|
||||
output_path: Path,
|
||||
match_hue: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
src_rgb, size = _load_rgb_float(source_path)
|
||||
ref_rgb = _load_reference_float(reference_path, size)
|
||||
|
||||
hsv_src = color.rgb2hsv(src_rgb)
|
||||
hsv_ref = color.rgb2hsv(ref_rgb)
|
||||
|
||||
hue = exposure.match_histograms(hsv_src[:, :, 0], hsv_ref[:, :, 0]) if match_hue else hsv_src[:, :, 0]
|
||||
sat = exposure.match_histograms(hsv_src[:, :, 1], hsv_ref[:, :, 1])
|
||||
val = exposure.match_histograms(hsv_src[:, :, 2], hsv_ref[:, :, 2])
|
||||
|
||||
result_hsv = np.stack([hue, sat, val], axis=2)
|
||||
_save_rgb_float(color.hsv2rgb(result_hsv), output_path)
|
||||
return {"match_hue": bool(match_hue)}
|
||||
|
||||
|
||||
def auto_sv(
|
||||
source_path: Path,
|
||||
reference_path: Path,
|
||||
output_path: Path,
|
||||
hist_first: bool = False,
|
||||
match_hue: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
src_rgb, size = _load_rgb_float(source_path)
|
||||
ref_rgb = _load_reference_float(reference_path, size)
|
||||
|
||||
hsv_src = color.rgb2hsv(src_rgb)
|
||||
hsv_ref = color.rgb2hsv(ref_rgb)
|
||||
|
||||
if hist_first:
|
||||
hue = exposure.match_histograms(hsv_src[:, :, 0], hsv_ref[:, :, 0]) if match_hue else hsv_src[:, :, 0]
|
||||
sat = exposure.match_histograms(hsv_src[:, :, 1], hsv_ref[:, :, 1])
|
||||
val = exposure.match_histograms(hsv_src[:, :, 2], hsv_ref[:, :, 2])
|
||||
hsv_src = np.stack([hue, sat, val], axis=2)
|
||||
|
||||
def loss_function(params: np.ndarray) -> float:
|
||||
ks, kv = params
|
||||
adj_s = np.clip(hsv_src[:, :, 1] * ks, 0.0, 1.0)
|
||||
adj_v = np.clip(hsv_src[:, :, 2] * kv, 0.0, 1.0)
|
||||
loss_s = np.mean((adj_s - hsv_ref[:, :, 1]) ** 2)
|
||||
loss_v = np.mean((adj_v - hsv_ref[:, :, 2]) ** 2)
|
||||
return float(loss_s + loss_v)
|
||||
|
||||
result = minimize(loss_function, np.array([1.0, 1.0]), method="Nelder-Mead", tol=1e-4)
|
||||
best_s, best_v = result.x
|
||||
|
||||
hsv_final = hsv_src.copy()
|
||||
hsv_final[:, :, 1] = np.clip(hsv_final[:, :, 1] * best_s, 0.0, 1.0)
|
||||
hsv_final[:, :, 2] = np.clip(hsv_final[:, :, 2] * best_v, 0.0, 1.0)
|
||||
|
||||
_save_rgb_float(color.hsv2rgb(hsv_final), output_path)
|
||||
return {
|
||||
"s_gain": float(best_s),
|
||||
"v_gain": float(best_v),
|
||||
"hist_first": bool(hist_first),
|
||||
"match_hue": bool(match_hue),
|
||||
}
|
||||
|
||||
|
||||
def run_postprocess(
|
||||
processor: str,
|
||||
source_path: Path,
|
||||
output_path: Path,
|
||||
reference_path: Path | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
params = params or {}
|
||||
if processor == "manual_sv":
|
||||
return adjust_sv(
|
||||
source_path,
|
||||
output_path,
|
||||
s_gain=float(params.get("s_gain", 1.0)),
|
||||
v_gain=float(params.get("v_gain", 1.0)),
|
||||
)
|
||||
|
||||
if reference_path is None:
|
||||
raise ValueError(f"{processor} requires a reference image")
|
||||
|
||||
if processor == "hsv_hist":
|
||||
return hsv_hist_match(
|
||||
source_path,
|
||||
reference_path,
|
||||
output_path,
|
||||
match_hue=bool(params.get("match_hue", False)),
|
||||
)
|
||||
if processor == "auto_sv":
|
||||
return auto_sv(source_path, reference_path, output_path, hist_first=False)
|
||||
if processor == "hist_auto_sv":
|
||||
return auto_sv(
|
||||
source_path,
|
||||
reference_path,
|
||||
output_path,
|
||||
hist_first=True,
|
||||
match_hue=bool(params.get("match_hue", False)),
|
||||
)
|
||||
|
||||
raise ValueError(f"Unknown postprocessor: {processor}")
|
||||
248
web_dehaze/server.py
Normal file
248
web_dehaze/server.py
Normal file
@@ -0,0 +1,248 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import mimetypes
|
||||
import threading
|
||||
import traceback
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from itertools import count
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
import pipeline
|
||||
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
JOBS: dict[str, dict[str, Any]] = {}
|
||||
JOB_IDS = count(1)
|
||||
JOB_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _json_bytes(payload: Any) -> bytes:
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
|
||||
|
||||
|
||||
def _new_job(kind: str, target, *args, **kwargs) -> dict[str, Any]:
|
||||
job_id = str(next(JOB_IDS))
|
||||
job = {
|
||||
"id": job_id,
|
||||
"kind": kind,
|
||||
"status": "running",
|
||||
"logs": [],
|
||||
"result": None,
|
||||
"error": None,
|
||||
}
|
||||
with JOB_LOCK:
|
||||
JOBS[job_id] = job
|
||||
|
||||
def log(message: str) -> None:
|
||||
with JOB_LOCK:
|
||||
job["logs"].append(message)
|
||||
job["logs"] = job["logs"][-400:]
|
||||
|
||||
def wrapped() -> None:
|
||||
try:
|
||||
job["result"] = target(log, *args, **kwargs)
|
||||
job["status"] = "done"
|
||||
except Exception as exc:
|
||||
job["status"] = "error"
|
||||
job["error"] = str(exc)
|
||||
log(traceback.format_exc())
|
||||
|
||||
threading.Thread(target=wrapped, daemon=True).start()
|
||||
return job
|
||||
|
||||
|
||||
def _run_dehaze_job(log, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
filename = payload["image"]
|
||||
methods = payload.get("methods") or []
|
||||
options = payload.get("options") or {}
|
||||
postprocessors = payload.get("postprocessors") or []
|
||||
post_sources = payload.get("post_sources") or []
|
||||
reference = payload.get("reference") or filename
|
||||
|
||||
completed = []
|
||||
failed = []
|
||||
for method in methods:
|
||||
try:
|
||||
result = pipeline.run_dehaze_method(filename, method, options.get(method, options), log)
|
||||
completed.append({"method": method, "path": pipeline.relpath(result)})
|
||||
except Exception as exc:
|
||||
failed.append({"method": method, "error": str(exc)})
|
||||
log(f"[{method}] 失败: {exc}")
|
||||
|
||||
if postprocessors:
|
||||
sources = post_sources or [item["method"] for item in completed]
|
||||
for source in sources:
|
||||
try:
|
||||
pipeline.run_postprocessors_for_source(
|
||||
filename,
|
||||
source,
|
||||
postprocessors,
|
||||
params=payload.get("post_params") or {},
|
||||
reference_filename=reference,
|
||||
log=log,
|
||||
)
|
||||
except Exception as exc:
|
||||
failed.append({"postprocess_source": source, "error": str(exc)})
|
||||
log(f"[后处理/{source}] 失败: {exc}")
|
||||
|
||||
return {"completed": completed, "failed": failed, "results": pipeline.get_results(filename)}
|
||||
|
||||
|
||||
def _run_post_job(log, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
filename = payload["image"]
|
||||
source = payload.get("source") or "DCP"
|
||||
processors = payload.get("processors") or []
|
||||
reference = payload.get("reference") or filename
|
||||
outputs = pipeline.run_postprocessors_for_source(
|
||||
filename,
|
||||
source,
|
||||
processors,
|
||||
params=payload.get("params") or {},
|
||||
reference_filename=reference,
|
||||
log=log,
|
||||
)
|
||||
return {
|
||||
"outputs": [pipeline.relpath(path) for path in outputs],
|
||||
"results": pipeline.get_results(filename),
|
||||
}
|
||||
|
||||
|
||||
class DehazeRequestHandler(BaseHTTPRequestHandler):
|
||||
server_version = "DehazeWeb/1.0"
|
||||
|
||||
def log_message(self, fmt: str, *args) -> None:
|
||||
print("[%s] %s" % (self.log_date_time_string(), fmt % args))
|
||||
|
||||
def _send(self, status: int, body: bytes, content_type: str = "application/json; charset=utf-8") -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _send_json(self, payload: Any, status: int = HTTPStatus.OK) -> None:
|
||||
self._send(int(status), _json_bytes(payload))
|
||||
|
||||
def _send_error_json(self, status: int, message: str) -> None:
|
||||
self._send_json({"error": message}, status)
|
||||
|
||||
def do_HEAD(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
target = STATIC_DIR / "index.html" if parsed.path == "/" else STATIC_DIR / parsed.path.removeprefix("/static/")
|
||||
if parsed.path == "/" or parsed.path.startswith("/static/"):
|
||||
target = target.resolve()
|
||||
static_root = STATIC_DIR.resolve()
|
||||
if static_root not in target.parents and target != static_root:
|
||||
self.send_response(HTTPStatus.FORBIDDEN)
|
||||
self.end_headers()
|
||||
return
|
||||
if target.exists() and target.is_file():
|
||||
content_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
|
||||
if target.suffix == ".html":
|
||||
content_type = "text/html; charset=utf-8"
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(target.stat().st_size))
|
||||
self.end_headers()
|
||||
return
|
||||
self.send_response(HTTPStatus.NOT_FOUND)
|
||||
self.end_headers()
|
||||
|
||||
def _read_json(self) -> dict[str, Any]:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if length <= 0:
|
||||
return {}
|
||||
raw = self.rfile.read(length).decode("utf-8")
|
||||
return json.loads(raw)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path
|
||||
query = parse_qs(parsed.query)
|
||||
try:
|
||||
if path == "/":
|
||||
self._serve_static("index.html")
|
||||
elif path.startswith("/static/"):
|
||||
self._serve_static(path.removeprefix("/static/"))
|
||||
elif path == "/asset":
|
||||
rel = unquote(query.get("path", [""])[0])
|
||||
self._serve_asset(rel)
|
||||
elif path == "/api/images":
|
||||
self._send_json({"images": pipeline.list_images()})
|
||||
elif path == "/api/capabilities":
|
||||
self._send_json(pipeline.capabilities())
|
||||
elif path == "/api/results":
|
||||
filename = query.get("image", [""])[0]
|
||||
self._send_json(pipeline.get_results(filename))
|
||||
elif path == "/api/job":
|
||||
job_id = query.get("id", [""])[0]
|
||||
with JOB_LOCK:
|
||||
job = JOBS.get(job_id)
|
||||
payload = dict(job) if job else None
|
||||
if payload is None:
|
||||
self._send_error_json(HTTPStatus.NOT_FOUND, "job not found")
|
||||
else:
|
||||
self._send_json(payload)
|
||||
else:
|
||||
self._send_error_json(HTTPStatus.NOT_FOUND, "not found")
|
||||
except Exception as exc:
|
||||
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
||||
|
||||
def do_POST(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
try:
|
||||
payload = self._read_json()
|
||||
if parsed.path == "/api/run":
|
||||
job = _new_job("dehaze", _run_dehaze_job, payload)
|
||||
self._send_json({"job": job})
|
||||
elif parsed.path == "/api/postprocess":
|
||||
job = _new_job("postprocess", _run_post_job, payload)
|
||||
self._send_json({"job": job})
|
||||
else:
|
||||
self._send_error_json(HTTPStatus.NOT_FOUND, "not found")
|
||||
except Exception as exc:
|
||||
self._send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
|
||||
|
||||
def _serve_static(self, name: str) -> None:
|
||||
target = (STATIC_DIR / name).resolve()
|
||||
if STATIC_DIR.resolve() not in target.parents and target != STATIC_DIR.resolve():
|
||||
self._send_error_json(HTTPStatus.FORBIDDEN, "forbidden")
|
||||
return
|
||||
if not target.exists() or not target.is_file():
|
||||
self._send_error_json(HTTPStatus.NOT_FOUND, "static file not found")
|
||||
return
|
||||
content_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
|
||||
if target.suffix == ".html":
|
||||
content_type = "text/html; charset=utf-8"
|
||||
elif target.suffix == ".css":
|
||||
content_type = "text/css; charset=utf-8"
|
||||
elif target.suffix == ".js":
|
||||
content_type = "application/javascript; charset=utf-8"
|
||||
self._send(HTTPStatus.OK, target.read_bytes(), content_type)
|
||||
|
||||
def _serve_asset(self, rel: str) -> None:
|
||||
target = pipeline.resolve_asset(rel)
|
||||
content_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
|
||||
self._send(HTTPStatus.OK, target.read_bytes(), content_type)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Local dehaze web console")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=7860)
|
||||
args = parser.parse_args()
|
||||
|
||||
server = ThreadingHTTPServer((args.host, args.port), DehazeRequestHandler)
|
||||
print(f"Dehaze web console: http://{args.host}:{args.port}")
|
||||
print(f"Images: {pipeline.IMAGE_DIR}")
|
||||
print(f"Results: {pipeline.RESULTS_DIR}")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
265
web_dehaze/static/app.js
Normal file
265
web_dehaze/static/app.js
Normal file
@@ -0,0 +1,265 @@
|
||||
const state = {
|
||||
images: [],
|
||||
capabilities: null,
|
||||
selectedImage: null,
|
||||
currentJob: null,
|
||||
pollTimer: null,
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function assetUrl(path) {
|
||||
return `/asset?path=${encodeURIComponent(path)}&t=${Date.now()}`;
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, options);
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || response.statusText);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!Number.isFinite(bytes)) return "";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function renderImages() {
|
||||
const box = $("imageList");
|
||||
box.innerHTML = "";
|
||||
state.images.forEach((image) => {
|
||||
const item = document.createElement("button");
|
||||
item.className = `image-item ${state.selectedImage === image.name ? "active" : ""}`;
|
||||
item.type = "button";
|
||||
item.innerHTML = `
|
||||
<span>${image.name}</span>
|
||||
<span class="image-meta">${image.width || "?"}x${image.height || "?"} · ${formatBytes(image.size)}</span>
|
||||
`;
|
||||
item.addEventListener("click", () => selectImage(image.name));
|
||||
box.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function renderMethods() {
|
||||
const box = $("methodList");
|
||||
box.innerHTML = "";
|
||||
const methods = state.capabilities?.methods || {};
|
||||
Object.entries(methods).forEach(([key, info]) => {
|
||||
const label = document.createElement("label");
|
||||
label.className = `check-item ${info.available ? "" : "disabled"}`;
|
||||
label.innerHTML = `
|
||||
<span>
|
||||
<strong>${info.label}</strong>
|
||||
<span class="method-meta">${info.available ? "ready" : `缺 ${info.module_ok ? "模型" : info.module}`}</span>
|
||||
</span>
|
||||
<input type="checkbox" value="${key}" ${info.available || key === "Baidu_API" || key === "DCP" ? "" : "disabled"} ${key === "DCP" ? "checked" : ""} />
|
||||
`;
|
||||
box.appendChild(label);
|
||||
});
|
||||
}
|
||||
|
||||
function renderPostprocessors() {
|
||||
const box = $("postList");
|
||||
box.innerHTML = "";
|
||||
const processors = state.capabilities?.postprocessors || {};
|
||||
Object.entries(processors).forEach(([key, info]) => {
|
||||
const label = document.createElement("label");
|
||||
label.className = "check-item";
|
||||
label.innerHTML = `
|
||||
<span><strong>${info.label}</strong></span>
|
||||
<input type="checkbox" value="${key}" ${key === "manual_sv" ? "checked" : ""} />
|
||||
`;
|
||||
box.appendChild(label);
|
||||
});
|
||||
}
|
||||
|
||||
function renderReferenceOptions() {
|
||||
const select = $("referenceImage");
|
||||
select.innerHTML = "";
|
||||
state.images.forEach((image) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = image.name;
|
||||
option.textContent = image.name;
|
||||
option.selected = image.name === state.selectedImage;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
function setJobState(text, status = "") {
|
||||
const box = $("jobState");
|
||||
box.textContent = text;
|
||||
box.className = `job-state ${status}`;
|
||||
}
|
||||
|
||||
async function selectImage(name) {
|
||||
state.selectedImage = name;
|
||||
$("currentTitle").textContent = name;
|
||||
renderImages();
|
||||
renderReferenceOptions();
|
||||
await refreshResults();
|
||||
}
|
||||
|
||||
function resultCard(title, path, exists = true) {
|
||||
const card = document.createElement("article");
|
||||
card.className = "result-card";
|
||||
const badge = exists ? '<span class="badge ok">ready</span>' : '<span class="badge pending">未生成</span>';
|
||||
const body = exists
|
||||
? `<div class="image-frame"><img src="${assetUrl(path)}" alt="${title}" loading="lazy" /></div>`
|
||||
: '<div class="image-frame"><span class="missing">暂无结果</span></div>';
|
||||
card.innerHTML = `<header><h3>${title}</h3>${badge}</header>${body}`;
|
||||
return card;
|
||||
}
|
||||
|
||||
function updatePostSourceOptions(results) {
|
||||
const select = $("postSource");
|
||||
const previous = select.value;
|
||||
select.innerHTML = "";
|
||||
const original = document.createElement("option");
|
||||
original.value = "original";
|
||||
original.textContent = "原图";
|
||||
select.appendChild(original);
|
||||
results.dehaze.filter((item) => item.exists).forEach((item) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = item.method;
|
||||
option.textContent = item.label;
|
||||
select.appendChild(option);
|
||||
});
|
||||
if ([...select.options].some((option) => option.value === previous)) {
|
||||
select.value = previous;
|
||||
} else if (results.dehaze.some((item) => item.method === "DCP" && item.exists)) {
|
||||
select.value = "DCP";
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshResults() {
|
||||
if (!state.selectedImage) return;
|
||||
const results = await api(`/api/results?image=${encodeURIComponent(state.selectedImage)}`);
|
||||
const grid = $("resultGrid");
|
||||
grid.innerHTML = "";
|
||||
grid.appendChild(resultCard(results.original.label, results.original.path, true));
|
||||
results.dehaze.forEach((item) => {
|
||||
grid.appendChild(resultCard(item.label, item.path, item.exists));
|
||||
});
|
||||
results.postprocess.forEach((item) => {
|
||||
grid.appendChild(resultCard(item.name, item.path, true));
|
||||
});
|
||||
updatePostSourceOptions(results);
|
||||
}
|
||||
|
||||
function selectedValues(containerId) {
|
||||
return [...$(containerId).querySelectorAll("input[type=checkbox]:checked")].map((input) => input.value);
|
||||
}
|
||||
|
||||
function postParams() {
|
||||
return {
|
||||
manual_sv: {
|
||||
s_gain: Number($("sGain").value),
|
||||
v_gain: Number($("vGain").value),
|
||||
},
|
||||
hsv_hist: {
|
||||
match_hue: $("matchHue").checked,
|
||||
},
|
||||
hist_auto_sv: {
|
||||
match_hue: $("matchHue").checked,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function startJob(endpoint, payload) {
|
||||
const response = await api(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
state.currentJob = response.job.id;
|
||||
$("logBox").textContent = "";
|
||||
setJobState("Running", "running");
|
||||
pollJob();
|
||||
}
|
||||
|
||||
async function pollJob() {
|
||||
if (!state.currentJob) return;
|
||||
clearTimeout(state.pollTimer);
|
||||
try {
|
||||
const job = await api(`/api/job?id=${encodeURIComponent(state.currentJob)}`);
|
||||
$("logBox").textContent = (job.logs || []).join("\n");
|
||||
$("logBox").scrollTop = $("logBox").scrollHeight;
|
||||
if (job.status === "running") {
|
||||
setJobState("Running", "running");
|
||||
state.pollTimer = setTimeout(pollJob, 1000);
|
||||
} else {
|
||||
setJobState(job.status === "done" ? "Done" : "Error", job.status);
|
||||
await refreshResults();
|
||||
}
|
||||
} catch (error) {
|
||||
setJobState("Error", "error");
|
||||
$("logBox").textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function runSelectedModels() {
|
||||
if (!state.selectedImage) return;
|
||||
const methods = selectedValues("methodList");
|
||||
const payload = {
|
||||
image: state.selectedImage,
|
||||
methods,
|
||||
options: {
|
||||
DCP: {
|
||||
sz: Number($("dcpSz").value),
|
||||
tx: Number($("dcpTx").value),
|
||||
},
|
||||
},
|
||||
};
|
||||
await startJob("/api/run", payload);
|
||||
}
|
||||
|
||||
async function runPostprocess() {
|
||||
if (!state.selectedImage) return;
|
||||
const processors = selectedValues("postList");
|
||||
const payload = {
|
||||
image: state.selectedImage,
|
||||
source: $("postSource").value,
|
||||
reference: $("referenceImage").value || state.selectedImage,
|
||||
processors,
|
||||
params: postParams(),
|
||||
};
|
||||
await startJob("/api/postprocess", payload);
|
||||
}
|
||||
|
||||
function bindControls() {
|
||||
$("runBtn").addEventListener("click", runSelectedModels);
|
||||
$("postBtn").addEventListener("click", runPostprocess);
|
||||
$("refreshBtn").addEventListener("click", refreshResults);
|
||||
$("sGain").addEventListener("input", () => {
|
||||
$("sGainValue").textContent = `${Math.round(Number($("sGain").value) * 100)}%`;
|
||||
});
|
||||
$("vGain").addEventListener("input", () => {
|
||||
$("vGainValue").textContent = `${Math.round(Number($("vGain").value) * 100)}%`;
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
bindControls();
|
||||
const [capabilities, images] = await Promise.all([api("/api/capabilities"), api("/api/images")]);
|
||||
state.capabilities = capabilities;
|
||||
state.images = images.images || [];
|
||||
$("envLine").textContent = capabilities.results_dir;
|
||||
renderMethods();
|
||||
renderPostprocessors();
|
||||
renderImages();
|
||||
if (state.images.length) {
|
||||
await selectImage(state.images[0].name);
|
||||
} else {
|
||||
$("currentTitle").textContent = "待去雾图片为空";
|
||||
}
|
||||
setJobState("Idle");
|
||||
}
|
||||
|
||||
init().catch((error) => {
|
||||
setJobState("Error", "error");
|
||||
$("logBox").textContent = error.stack || error.message;
|
||||
});
|
||||
93
web_dehaze/static/index.html
Normal file
93
web_dehaze/static/index.html
Normal file
@@ -0,0 +1,93 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Dehaze Console</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<aside class="control-panel">
|
||||
<div class="brand">
|
||||
<span class="brand-mark"></span>
|
||||
<div>
|
||||
<h1>Dehaze Console</h1>
|
||||
<p id="envLine">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel-section">
|
||||
<h2>待去雾图片</h2>
|
||||
<div id="imageList" class="image-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel-section">
|
||||
<h2>模型</h2>
|
||||
<div id="methodList" class="check-grid"></div>
|
||||
<div class="param-grid">
|
||||
<label>
|
||||
<span>DCP 窗口</span>
|
||||
<input id="dcpSz" type="number" min="3" max="99" step="1" value="10" />
|
||||
</label>
|
||||
<label>
|
||||
<span>DCP tx</span>
|
||||
<input id="dcpTx" type="number" min="0.01" max="1" step="0.01" value="0.20" />
|
||||
</label>
|
||||
</div>
|
||||
<button id="runBtn" class="primary-btn">运行选中模型</button>
|
||||
</section>
|
||||
|
||||
<section class="panel-section">
|
||||
<h2>后处理</h2>
|
||||
<label class="field">
|
||||
<span>源图</span>
|
||||
<select id="postSource"></select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>参考图</span>
|
||||
<select id="referenceImage"></select>
|
||||
</label>
|
||||
<div id="postList" class="check-grid compact"></div>
|
||||
<div class="slider-row">
|
||||
<span>S</span>
|
||||
<input id="sGain" type="range" min="0" max="2.5" step="0.01" value="1" />
|
||||
<strong id="sGainValue">100%</strong>
|
||||
</div>
|
||||
<div class="slider-row">
|
||||
<span>V</span>
|
||||
<input id="vGain" type="range" min="0" max="2.5" step="0.01" value="1" />
|
||||
<strong id="vGainValue">100%</strong>
|
||||
</div>
|
||||
<label class="toggle-line">
|
||||
<input id="matchHue" type="checkbox" />
|
||||
<span>匹配 H 通道</span>
|
||||
</label>
|
||||
<button id="postBtn" class="secondary-btn">生成后处理</button>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="workspace">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">当前图片</p>
|
||||
<h2 id="currentTitle">未选择</h2>
|
||||
</div>
|
||||
<div id="jobState" class="job-state">Idle</div>
|
||||
</header>
|
||||
|
||||
<section id="resultGrid" class="result-grid"></section>
|
||||
|
||||
<section class="log-panel">
|
||||
<div class="log-head">
|
||||
<h2>日志</h2>
|
||||
<button id="refreshBtn" class="text-btn">刷新</button>
|
||||
</div>
|
||||
<pre id="logBox"></pre>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
428
web_dehaze/static/style.css
Normal file
428
web_dehaze/static/style.css
Normal file
@@ -0,0 +1,428 @@
|
||||
:root {
|
||||
--paper: #f4f0e8;
|
||||
--panel: #fffaf1;
|
||||
--ink: #1e2520;
|
||||
--muted: #6d756f;
|
||||
--line: #d8d0c3;
|
||||
--green: #1f6b57;
|
||||
--green-dark: #124839;
|
||||
--cobalt: #295f9f;
|
||||
--amber: #c1842d;
|
||||
--red: #b3473f;
|
||||
--shadow: 0 18px 60px rgba(33, 37, 31, 0.12);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--ink);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(31, 107, 87, 0.06) 1px, transparent 1px),
|
||||
linear-gradient(0deg, rgba(31, 107, 87, 0.05) 1px, transparent 1px),
|
||||
var(--paper);
|
||||
background-size: 28px 28px;
|
||||
font-family: "Aptos", "Noto Sans SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 360px) 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
padding: 24px 20px;
|
||||
border-right: 1px solid var(--line);
|
||||
background: rgba(255, 250, 241, 0.94);
|
||||
box-shadow: var(--shadow);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding-bottom: 22px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 2px solid var(--ink);
|
||||
background:
|
||||
linear-gradient(135deg, transparent 46%, var(--ink) 47%, var(--ink) 53%, transparent 54%),
|
||||
linear-gradient(45deg, var(--green) 0 48%, var(--amber) 48% 100%);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.brand p,
|
||||
.eyebrow {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.panel-section h2,
|
||||
.log-head h2 {
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.image-list,
|
||||
.check-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.image-item,
|
||||
.check-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 42px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.image-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-item.active {
|
||||
border-color: var(--green);
|
||||
background: rgba(31, 107, 87, 0.12);
|
||||
}
|
||||
|
||||
.image-meta,
|
||||
.method-meta {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.check-item.disabled {
|
||||
color: var(--muted);
|
||||
background: rgba(216, 208, 195, 0.34);
|
||||
}
|
||||
|
||||
.check-item input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--green);
|
||||
}
|
||||
|
||||
.compact {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.param-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.param-grid label,
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.param-grid input,
|
||||
.field select {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fffdf8;
|
||||
color: var(--ink);
|
||||
padding: 7px 8px;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.primary-btn,
|
||||
.secondary-btn,
|
||||
.text-btn {
|
||||
min-height: 42px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary-btn,
|
||||
.secondary-btn {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.primary-btn:hover {
|
||||
background: var(--green-dark);
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
background: var(--cobalt);
|
||||
}
|
||||
|
||||
.secondary-btn:hover {
|
||||
background: #1e4d83;
|
||||
}
|
||||
|
||||
.text-btn {
|
||||
padding: 0 12px;
|
||||
border-color: var(--line);
|
||||
background: #fffdf8;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.slider-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr 52px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.slider-row input {
|
||||
accent-color: var(--green);
|
||||
}
|
||||
|
||||
.slider-row strong {
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.toggle-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.toggle-line input {
|
||||
accent-color: var(--green);
|
||||
}
|
||||
|
||||
.workspace {
|
||||
min-width: 0;
|
||||
padding: 26px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.topbar h2 {
|
||||
margin-top: 4px;
|
||||
font-size: clamp(22px, 3vw, 38px);
|
||||
line-height: 1.05;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.job-state {
|
||||
min-width: 92px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 250, 241, 0.82);
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.job-state.running {
|
||||
color: var(--cobalt);
|
||||
border-color: rgba(41, 95, 159, 0.35);
|
||||
}
|
||||
|
||||
.job-state.error {
|
||||
color: var(--red);
|
||||
border-color: rgba(179, 71, 63, 0.4);
|
||||
}
|
||||
|
||||
.job-state.done {
|
||||
color: var(--green);
|
||||
border-color: rgba(31, 107, 87, 0.4);
|
||||
}
|
||||
|
||||
.result-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 14px;
|
||||
max-height: calc(100vh - 300px);
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-height: 230px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 250, 241, 0.82);
|
||||
}
|
||||
|
||||
.result-card header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-height: 42px;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.result-card h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex: 0 0 auto;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.badge.ok {
|
||||
color: var(--green);
|
||||
border-color: rgba(31, 107, 87, 0.4);
|
||||
}
|
||||
|
||||
.badge.pending {
|
||||
color: var(--amber);
|
||||
border-color: rgba(193, 132, 45, 0.42);
|
||||
}
|
||||
|
||||
.image-frame {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 188px;
|
||||
padding: 8px;
|
||||
background:
|
||||
linear-gradient(45deg, rgba(30, 37, 32, 0.05) 25%, transparent 25% 75%, rgba(30, 37, 32, 0.05) 75%),
|
||||
linear-gradient(45deg, rgba(30, 37, 32, 0.05) 25%, transparent 25% 75%, rgba(30, 37, 32, 0.05) 75%);
|
||||
background-position: 0 0, 8px 8px;
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
|
||||
.image-frame img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 38vh;
|
||||
object-fit: contain;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.missing {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
margin-top: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(30, 37, 32, 0.92);
|
||||
color: #e9eadf;
|
||||
}
|
||||
|
||||
.log-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 44px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.log-head h2 {
|
||||
margin: 0;
|
||||
color: #f7f2e8;
|
||||
}
|
||||
|
||||
.log-panel .text-btn {
|
||||
min-height: 30px;
|
||||
background: transparent;
|
||||
color: #f7f2e8;
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
#logBox {
|
||||
height: 172px;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
font-family: "Cascadia Mono", "Noto Sans Mono", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.workspace {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
align-items: start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.result-grid {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user