整合去雾网页工具

This commit is contained in:
admin
2026-06-10 17:42:11 +08:00
commit 6db15ebc3f
101 changed files with 10167 additions and 0 deletions

13
web_dehaze/README.md Normal file
View 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
View 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
View 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
View 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
View 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;
});

View 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
View 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;
}
}