提升暴雨产图效率

This commit is contained in:
wzy-warehouse
2026-06-21 14:52:23 +08:00
parent 5169ed2f33
commit 3e0f5a94cb
47 changed files with 513 additions and 188 deletions
+107 -82
View File
@@ -36,8 +36,7 @@ _thread_pool = concurrent.futures.ThreadPoolExecutor(
)
logger.info(f"QGIS 线程池初始化: {_worker_threads} workers")
# 去重锁in_progress_locks[disaster_time] = asyncio.Lock
# 同一灾害时间的并发请求会排队,避免重复产图
# 去重锁
_in_progress_locks: dict[str, asyncio.Lock] = {}
_locks_lock = asyncio.Lock()
@@ -375,7 +374,62 @@ async def export_map(req: QgisMapExportRequest):
def _generate_batch_maps(models: list, config: dict, disaster_time: str) -> None:
"""通过 QGIS Python 3.12 子进程批量生成专题图(单次 DLL 加载)"""
"""并行启动多个 QGIS 子进程,每个处理一部分模板"""
import json, math, concurrent.futures
# 并行度:取配置的 worker 数和模板数的较小值
max_workers = getattr(settings, "QGIS_PARALLEL_WORKERS", 4)
workers = min(max_workers, len(models))
chunk_size = math.ceil(len(models) / workers)
chunks = []
for i in range(0, len(models), chunk_size):
chunks.append(models[i:i + chunk_size])
logger.info(
f"[批量产图] {len(models)} 张图 → {len(chunks)} 个并行子进程 "
f"(每进程 {chunk_size} 张)"
)
errors = []
all_results = []
def _run_chunk(chunk_models: list, chunk_idx: int) -> list:
"""单个子进程处理一批模板"""
return _generate_maps_subprocess(chunk_models, config, chunk_idx)
with concurrent.futures.ThreadPoolExecutor(max_workers=len(chunks)) as executor:
futures = {
executor.submit(_run_chunk, chunk, i): i
for i, chunk in enumerate(chunks)
}
for future in concurrent.futures.as_completed(futures):
chunk_idx = futures[future]
try:
results = future.result()
all_results.extend(results)
except Exception as e:
logger.error(f"[批量产图] 子进程 {chunk_idx} 失败: {e}")
errors.append(str(e))
if errors and not all_results:
raise RuntimeError(f"所有子进程均失败: {'; '.join(errors[:2])}")
success_count = sum(1 for r in all_results if "error" not in r)
fail_count = len(all_results) - success_count
logger.info(f"[批量产图] 完成: 成功={success_count}, 失败={fail_count}")
for r in all_results:
if "error" not in r:
logger.info(f" OK {r.get('output', 'N/A')}")
else:
logger.error(f" FAIL {r['name']}: {r.get('error', 'unknown')}")
if success_count == 0 and fail_count > 0:
first_err = all_results[0].get("error", "unknown")
raise RuntimeError(f"所有模型均失败 ({fail_count}张): {first_err}")
def _generate_maps_subprocess(chunk_models: list, config: dict, chunk_idx: int) -> list:
"""单个 QGIS 子进程,处理一批模板,返回结果列表"""
import json
import subprocess
import tempfile
@@ -383,93 +437,64 @@ def _generate_batch_maps(models: list, config: dict, disaster_time: str) -> None
get_qgis_python_path, get_runner_script, build_qgis_subprocess_env,
)
request_data = json.dumps(
{"config": config, "models": chunk_models},
ensure_ascii=False,
)
tmp_json = tempfile.NamedTemporaryFile(
suffix=".json", delete=False, mode="w", encoding="utf-8"
)
tmp_json.write(request_data)
tmp_json.close()
try:
logger.info(f"[批量产图] 开始: {len(models)} 张专题图, batch={disaster_time}")
from config import settings
qgis_root = getattr(settings, "QGIS_ROOT", "D:/QGIS")
python_path = get_qgis_python_path(qgis_root)
if not python_path:
raise RuntimeError("未找到 QGIS Python 3.12 解释器")
runner_script = get_runner_script()
cmd = [python_path, runner_script, tmp_json.name]
# 构建子进程请求 JSON —— 批量格式
request_data = json.dumps(
{"config": config, "models": models},
ensure_ascii=False,
logger.info(f"[子进程{chunk_idx}] 启动: {len(chunk_models)} 张图")
result = subprocess.run(
cmd,
capture_output=True,
timeout=600,
env=build_qgis_subprocess_env(qgis_root),
)
# 将请求 JSON 写入临时文件(避免 stdin 管道问题)
tmp_json = tempfile.NamedTemporaryFile(
suffix=".json", delete=False, mode="w", encoding="utf-8"
)
tmp_json.write(request_data)
tmp_json.close()
finally:
try:
from config import settings
qgis_root = getattr(settings, "QGIS_ROOT", "D:/QGIS")
python_path = get_qgis_python_path(qgis_root)
if not python_path:
raise RuntimeError("未找到 QGIS Python 3.12 解释器")
runner_script = get_runner_script()
cmd = [python_path, runner_script, tmp_json.name]
os.remove(tmp_json.name)
except OSError:
pass
logger.info(f"[批量产图] 启动 QGIS 子进程: {python_path}...")
stdout_text = result.stdout.decode("utf-8", errors="replace").strip()
parsed_output = None
if stdout_text:
for line in reversed(stdout_text.split("\n")):
line = line.strip()
if line.startswith("{"):
try:
parsed_output = json.loads(line)
break
except json.JSONDecodeError:
continue
result = subprocess.run(
cmd,
capture_output=True,
timeout=600, # 10 分钟超时(15 张模板 × ~40s/张)
env=build_qgis_subprocess_env(qgis_root),
)
finally:
try:
os.remove(tmp_json.name)
except OSError:
pass
if parsed_output is not None:
results = parsed_output.get("results", [])
ok = sum(1 for r in results if "error" not in r)
logger.info(f"[子进程{chunk_idx}] 完成: {ok}/{len(results)}")
return results
# 解析子进程输出 —— 优先检查 stdout 是否有有效结果
stdout_text = result.stdout.decode("utf-8", errors="replace").strip()
parsed_output = None
if stdout_text:
for line in reversed(stdout_text.split("\n")):
line = line.strip()
if line.startswith("{"):
try:
parsed_output = json.loads(line)
break
except json.JSONDecodeError:
continue
if parsed_output is not None:
batch_results = parsed_output.get("results", [])
success_count = sum(1 for r in batch_results if "error" not in r)
fail_count = len(batch_results) - success_count
logger.info(
f"[批量产图] 完成: 成功={success_count}, 失败={fail_count}"
)
for r in batch_results:
if "error" not in r:
logger.info(f" OK {r.get('output', 'N/A')}")
else:
logger.error(f" FAIL {r.get('error', 'unknown')}")
if success_count == 0 and fail_count > 0:
first_err = batch_results[0].get("error", "unknown")
raise RuntimeError(
f"QGIS 子进程所有模型均失败 ({fail_count}张): {first_err}"
)
elif result.returncode != 0:
stderr_text = result.stderr.decode("utf-8", errors="replace").strip()
logger.error(f"[批量产图] QGIS 子进程失败 (exit={result.returncode}):")
for line in stderr_text.split("\n"):
logger.error(f" {line}")
raise RuntimeError(
f"QGIS 子进程失败: {stderr_text[:300]}"
)
else:
logger.warning("[批量产图] 子进程无有效输出,exit code = 0")
except subprocess.TimeoutExpired:
logger.error(f"[批量产图] QGIS 子进程超时 (600s)")
raise
except Exception as e:
logger.error(f"[批量产图] 产图失败: {e}", exc_info=True)
raise
if result.returncode != 0:
stderr_text = result.stderr.decode("utf-8", errors="replace").strip()
raise RuntimeError(f"[子进程{chunk_idx}] 失败: {stderr_text[:200]}")
logger.warning(f"[子进程{chunk_idx}] 无输出")
return []
# ============================================================
# 清理函数
+6
View File
@@ -29,6 +29,12 @@ STATIC_LAYERS = {
"traffic_railway": {"table": "base.traffic_railway", "gpkg": "traffic_railway.gpkg"},
"traffic_township": {"table": "base.traffic_township", "gpkg": "traffic_township.gpkg"},
"traffic_trunk_line": {"table": "base.traffic_trunk_line", "gpkg": "traffic_trunk_line.gpkg"},
# ── 新增静态层 ──
"积水点": {"table": "base.hazard_hydrops", "gpkg": "hazard_hydrops.gpkg"},
"排水口": {"table": "base.lifeline_outfall", "gpkg": "lifeline_outfall.gpkg"},
"供水管网": {"table": "base.lifeline_pipe", "gpkg": "lifeline_pipe.gpkg"},
"risk_census_population": {"table": "base.risk_census_population", "gpkg": "risk_census_population.gpkg"},
"西安乡镇": {"table": "base.sx_xa_towns", "gpkg": "sx_xa_towns.gpkg"},
}
# ============================================================
+4 -4
View File
@@ -16,21 +16,21 @@ logger = get_logger("api")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期:启动时预加载模型"""
"""应用生命周期:启动时预加载 DBN 模型"""
logger.info("正在预加载DBN模型...")
get_rainfall_model()
get_earthquake_model()
logger.info("DBN模型预加载完成")
# 检测 QGIS 子进程环境
# 检测 QGIS 子进程环境(产图时按需启动子进程)
qgis_root = getattr(settings, "QGIS_ROOT", None)
if qgis_root:
try:
from app.services.qgis.qgis_env import is_qgis_available
if is_qgis_available(qgis_root):
logger.info("QGIS 环境检测通过(子进程模式)")
logger.info("QGIS 环境检测通过")
else:
logger.warning("QGIS 环境不可用(未找到 Python 3.12 解释器),专题图功能降级")
logger.warning("QGIS 环境不可用,专题图功能降级")
except Exception as e:
logger.error(f"QGIS 环境检测失败: {e}")
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+94 -94
View File
@@ -32,8 +32,8 @@
"ymax": 33.622777448228966
}
},
"F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨地灾风险区分布图.qgz": {
"file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\41f47cbdd9c9c8d0.qgz",
"F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨泥石流潜在隐患点及农作物分布图.qgz": {
"file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\32dc5df99342a7da.qgz",
"layout": "A3",
"texts": {
"mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图",
@@ -42,14 +42,14 @@
"info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇"
},
"extent": {
"xmin": 107.87768948431949,
"ymin": 32.972841920667406,
"xmax": 108.96006631597123,
"ymax": 33.68287129318787
"xmin": 107.95707951802657,
"ymin": 32.9127480757085,
"xmax": 109.03945634967837,
"ymax": 33.622777448228966
}
},
"F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨城市生命线工程分布图.qgz": {
"file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\82ba30aacf8a53dd.qgz",
"F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨防汛物资分布图.qgz": {
"file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\f0cf04a82b3bfb58.qgz",
"layout": "A3",
"texts": {
"mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图",
@@ -58,10 +58,10 @@
"info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇"
},
"extent": {
"xmin": 107.46472706539424,
"ymin": 32.921385746843555,
"xmax": 108.51635924537791,
"ymax": 33.61124690706137
"xmin": 107.95707951802657,
"ymin": 32.9127480757085,
"xmax": 109.03945634967837,
"ymax": 33.622777448228966
}
},
"F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨山洪潜在隐患点及人口分布图.qgz": {
@@ -96,40 +96,8 @@
"ymax": 33.622777448228966
}
},
"F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨救援队伍分布图.qgz": {
"file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\d8c1e99a6640a0fa.qgz",
"layout": "A3",
"texts": {
"mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图",
"mapTime": "制图时间:2020年11月",
"mapUint": "制图单位:四川省地震应急分服务中心",
"info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇"
},
"extent": {
"xmin": 107.95707951802657,
"ymin": 32.9127480757085,
"xmax": 109.03945634967837,
"ymax": 33.622777448228966
}
},
"F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨泥石流潜在隐患点及人口分布图.qgz": {
"file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\5a480fbeb8b2c66c.qgz",
"layout": "A3",
"texts": {
"mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图",
"mapTime": "制图时间:2020年11月",
"mapUint": "制图单位:四川省地震应急分服务中心",
"info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇"
},
"extent": {
"xmin": 107.95707951802657,
"ymin": 32.9127480757085,
"xmax": 109.03945634967837,
"ymax": 33.622777448228966
}
},
"F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨泥石流潜在隐患点及农作物分布图.qgz": {
"file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\32dc5df99342a7da.qgz",
"F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨附近医院分布图.qgz": {
"file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\ef423854adc709b4.qgz",
"layout": "A3",
"texts": {
"mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图",
@@ -160,6 +128,38 @@
"ymax": 33.622777448228966
}
},
"F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨地灾风险区分布图.qgz": {
"file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\41f47cbdd9c9c8d0.qgz",
"layout": "A3",
"texts": {
"mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图",
"mapTime": "制图时间:2020年11月",
"mapUint": "制图单位:四川省地震应急分服务中心",
"info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇"
},
"extent": {
"xmin": 107.87768948431949,
"ymin": 32.972841920667406,
"xmax": 108.96006631597123,
"ymax": 33.68287129318787
}
},
"F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨附近水库分布图.qgz": {
"file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\dc2534d5788a805f.qgz",
"layout": "A3",
"texts": {
"mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图",
"mapTime": "制图时间:2020年11月",
"mapUint": "制图单位:四川省地震应急分服务中心",
"info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇"
},
"extent": {
"xmin": 107.95707951802657,
"ymin": 32.9127480757085,
"xmax": 109.03945634967837,
"ymax": 33.622777448228966
}
},
"F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨滑坡潜在隐患点及农作物分布图.qgz": {
"file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\bc46a5234f68e54e.qgz",
"layout": "A3",
@@ -176,6 +176,54 @@
"ymax": 33.622777448228966
}
},
"F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨救援队伍分布图.qgz": {
"file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\d8c1e99a6640a0fa.qgz",
"layout": "A3",
"texts": {
"mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图",
"mapTime": "制图时间:2020年11月",
"mapUint": "制图单位:四川省地震应急分服务中心",
"info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇"
},
"extent": {
"xmin": 107.95707951802657,
"ymin": 32.9127480757085,
"xmax": 109.03945634967837,
"ymax": 33.622777448228966
}
},
"F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨城市生命线工程分布图.qgz": {
"file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\82ba30aacf8a53dd.qgz",
"layout": "A3",
"texts": {
"mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图",
"mapTime": "制图时间:2020年11月",
"mapUint": "制图单位:四川省地震应急分服务中心",
"info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇"
},
"extent": {
"xmin": 107.46472706539424,
"ymin": 32.921385746843555,
"xmax": 108.51635924537791,
"ymax": 33.61124690706137
}
},
"F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨泥石流潜在隐患点及人口分布图.qgz": {
"file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\5a480fbeb8b2c66c.qgz",
"layout": "A3",
"texts": {
"mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图",
"mapTime": "制图时间:2020年11月",
"mapUint": "制图单位:四川省地震应急分服务中心",
"info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇"
},
"extent": {
"xmin": 107.95707951802657,
"ymin": 32.9127480757085,
"xmax": 109.03945634967837,
"ymax": 33.622777448228966
}
},
"F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨避难场所分布图.qgz": {
"file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\d94f0d54dd607cbf.qgz",
"layout": "A3",
@@ -191,54 +239,6 @@
"xmax": 108.51635924537791,
"ymax": 33.61124690706137
}
},
"F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨防汛物资分布图.qgz": {
"file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\f0cf04a82b3bfb58.qgz",
"layout": "A3",
"texts": {
"mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图",
"mapTime": "制图时间:2020年11月",
"mapUint": "制图单位:四川省地震应急分服务中心",
"info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇"
},
"extent": {
"xmin": 107.95707951802657,
"ymin": 32.9127480757085,
"xmax": 109.03945634967837,
"ymax": 33.622777448228966
}
},
"F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨附近医院分布图.qgz": {
"file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\ef423854adc709b4.qgz",
"layout": "A3",
"texts": {
"mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图",
"mapTime": "制图时间:2020年11月",
"mapUint": "制图单位:四川省地震应急分服务中心",
"info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇"
},
"extent": {
"xmin": 107.95707951802657,
"ymin": 32.9127480757085,
"xmax": 109.03945634967837,
"ymax": 33.622777448228966
}
},
"F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨附近水库分布图.qgz": {
"file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\dc2534d5788a805f.qgz",
"layout": "A3",
"texts": {
"mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图",
"mapTime": "制图时间:2020年11月",
"mapUint": "制图单位:四川省地震应急分服务中心",
"info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇"
},
"extent": {
"xmin": 107.95707951802657,
"ymin": 32.9127480757085,
"xmax": 109.03945634967837,
"ymax": 33.622777448228966
}
}
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7
View File
@@ -27,6 +27,7 @@ GPKG_DIR = os.path.join(project_root, "app", "data", "gpkg")
# 静态图层定义: {显示名: (schema.table, gpkg文件名)}
STATIC_LAYERS = [
# ── 基础底图(已导出)──
("水库", "qgis.rivers", "rivers.gpkg"),
("市州驻地", "qgis.sx_capital", "sx_capital.gpkg"),
("河流", "qgis.river", "river.gpkg"),
@@ -43,6 +44,12 @@ STATIC_LAYERS = [
("traffic_railway", "qgis.traffic_railway", "traffic_railway.gpkg"),
("traffic_township", "qgis.traffic_township", "traffic_township.gpkg"),
("traffic_trunk_line", "qgis.traffic_trunk_line", "traffic_trunk_line.gpkg"),
# ── 新增静态表(消除数据库连接)──
("积水点", "qgis.hazard_hydrops", "hazard_hydrops.gpkg"),
("排水口", "qgis.lifeline_outfall", "lifeline_outfall.gpkg"),
("供水管网", "qgis.lifeline_pipe", "lifeline_pipe.gpkg"),
("风险人口", "qgis.risk_census_population", "risk_census_population.gpkg"),
("西安乡镇", "qgis.sx_xa_towns", "sx_xa_towns.gpkg"),
]
+82
View File
@@ -0,0 +1,82 @@
"""
扫描所有模板,找出所有引用的 PostgreSQL 表,列出哪些已导出 GPKG、哪些还没。
"""
import zipfile, re, os, sys
TEMPLATE_BASE = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"app", "data", "template"
)
# 当前已导出的 GPKG 表(从 qgis_mappings.py / export_static_layers.py
EXPORTED = {
"rivers", "sx_capital", "river", "active_fault", "sx",
"sx_street", "sx_xa_county", "sx_xa_county_boundary",
"sx_zb_county_boundary", "sx_zb_city", "sx_zb_county",
"traffic_expressway", "traffic_provincial", "traffic_railway",
"traffic_township", "traffic_trunk_line",
}
# 避难场所 OGR→PG 映射的表(已由 TemplateModifier 转换)
OGR_TABLES = {
"shelter_park", "shelter_school", "shelter_cultural",
"shelter_defence", "shelter_gymnasium", "shelter_square", "shelter_stay",
}
all_tables = {} # {table_name: set of templates}
for event_type in ["rainfall", "earthquake"]:
tdir = os.path.join(TEMPLATE_BASE, event_type)
if not os.path.isdir(tdir):
continue
for tf in sorted(os.listdir(tdir)):
if not tf.endswith(".qgz") or tf.startswith("tmp"):
continue
tpath = os.path.join(tdir, tf)
with zipfile.ZipFile(tpath, "r") as z:
for item in z.infolist():
if item.filename.endswith(".qgs"):
content = z.read(item.filename).decode("utf-8")
# 匹配 datasource 中的 table="schema"."name"
for m in re.finditer(r'table="(\w+)"\."(\w+)"', content):
tbl = m.group(2)
all_tables.setdefault(tbl, set()).add(tf)
# 也检查 provider=postgres 但没有 table= 的(如震中)
# 匹配 dbname='...' 格式
for m in re.finditer(r"dbname='([^']+)'", content):
db = m.group(1)
if db == 'xxgx_client_ya':
all_tables.setdefault(f"[外部DB: {db}]", set()).add(tf)
print("=" * 60)
print("已导出 GPKG (16):")
for t in sorted(EXPORTED):
if t in all_tables:
print(f"{t}")
print()
print("避难场所 OGR→PG (7):")
for t in sorted(OGR_TABLES):
print(f"{t}")
print()
print("未导出,需要新增:")
need_export = []
for tbl, tmps in sorted(all_tables.items()):
if tbl.startswith("[外部DB:"):
continue
if tbl in EXPORTED or tbl in OGR_TABLES:
continue
need_export.append(tbl)
print(f"{tbl}{', '.join(sorted(tmps))}")
print()
print("外部数据库(无法静态化):")
for tbl, tmps in sorted(all_tables.items()):
if tbl.startswith("[外部DB:"):
print(f" 🔗 {tbl}{', '.join(sorted(tmps))}")
print()
print(f"总结: 需要新增导出 {len(need_export)} 张表")
if need_export:
print(f" {', '.join(sorted(need_export))}")
+2 -2
View File
@@ -41,12 +41,12 @@ class MapService:
t0 = time.time()
if is_cache_hit:
logger.info(f"[缓存命中] {template_name}")
project.clear()
project, texts, extent = template_cache.restore_template(template_path)
template_cache.reset_project_state(project, texts, extent)
# FlagDontResolveLayers 跳过了数据库连接,手动触发 PostgreSQL 图层解析
self._resolve_postgres_layers(project)
else:
logger.info(f"[首次加载] {template_name}")
project.clear()
modifier = TemplateModifier(self.config)
actual_path = modifier.modify(template_path)
project.read(actual_path)
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""
QGIS 常驻 Daemon 进程。
由主进程在 FastAPI lifespan 中启动,通过 stdin/stdout JSON 行协议通信。
启动时预加载所有模板到内存缓存,后续请求秒级响应。
协议(每行一个完整 JSON):
输入(stdin: {"config": {...}, "models": [...], "result_file": "path/to/result.json"}
输出(result_file: {"results": [...]}
控制: {"action": "shutdown"}
生命周期:
1. FastAPI 启动 → 启动 daemon 子进程
2. daemon 初始化 QGIS,加载磁盘缓存
3. 收到请求 → 处理模板 → 结果写入 result_file → stdout 输出完成标记
4. FastAPI 关闭 → 发送 shutdown → daemon 退出
"""
import json
import os
import sys
import time
# ============================================================
# 环境初始化(必须在 QGIS import 之前)
# ============================================================
QGIS_ROOT = os.environ.get("QGIS_ROOT", "D:/QGIS")
def _detect_qgis_app_dir():
for name in ("qgis-ltr", "qgis"):
d = os.path.join(QGIS_ROOT, "apps", name)
if os.path.isdir(d):
return d
raise RuntimeError(f"未找到 QGIS 应用目录: {QGIS_ROOT}\\apps\\qgis-ltr 或 qgis")
def _setup_environment():
os.environ["PYTHONUTF8"] = "1"
os.environ["GDAL_FILENAME_IS_UTF8"] = "YES"
os.environ["VSI_CACHE"] = "TRUE"
os.environ["VSI_CACHE_SIZE"] = "1000000"
if sys.platform == "win32":
import ctypes
qgis_app_dir = _detect_qgis_app_dir()
for dll_dir in [
os.path.join(qgis_app_dir, "bin"),
os.path.join(QGIS_ROOT, "apps", "Qt5", "bin"),
os.path.join(QGIS_ROOT, "apps", "gdal", "lib"),
]:
if os.path.isdir(dll_dir):
os.add_dll_directory(dll_dir)
_preload_dlls = [
"qgis_core.dll", "qgispython.dll",
"Qt5Core.dll", "Qt5Gui.dll", "Qt5Widgets.dll",
"Qt5Network.dll", "Qt5Svg.dll", "Qt5Xml.dll",
"Qt5Concurrent.dll", "Qt5PrintSupport.dll",
]
for dll_dir in [
os.path.join(qgis_app_dir, "bin"),
os.path.join(QGIS_ROOT, "apps", "Qt5", "bin"),
os.path.join(QGIS_ROOT, "apps", "gdal", "lib"),
]:
for dll_name in _preload_dlls:
dll_path = os.path.join(dll_dir, dll_name)
if os.path.isfile(dll_path):
try:
ctypes.WinDLL(dll_path)
except OSError:
pass
def _setup_python_path():
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
if project_root not in sys.path:
sys.path.insert(0, project_root)
qgis_app_dir = _detect_qgis_app_dir()
qgis_python = os.path.join(qgis_app_dir, "python")
if os.path.isdir(qgis_python) and qgis_python not in sys.path:
sys.path.insert(0, qgis_python)
def _scan_templates() -> list[str]:
"""扫描所有模板文件,返回绝对路径列表"""
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
template_base = os.path.join(project_root, "app", "data", "template")
templates = []
for event_type in ["rainfall", "earthquake"]:
tdir = os.path.join(template_base, event_type)
if not os.path.isdir(tdir):
continue
for tf in sorted(os.listdir(tdir)):
if tf.endswith(".qgz") and not tf.startswith("tmp"):
templates.append(os.path.join(tdir, tf).replace("\\", "/"))
return templates
# ============================================================
# 主逻辑
# ============================================================
def main():
_setup_environment()
_setup_python_path()
from qgis.core import QgsApplication, QgsProject
# 初始化 QGIS(只做一次)
qgis_app_dir = _detect_qgis_app_dir()
QgsApplication.setPrefixPath(qgis_app_dir, True)
qgs_app = QgsApplication([], False)
qgs_app.initQgis()
from app.services.qgis.map_service import MapService, template_cache
from app.services.qgis.template_modifier import TemplateModifier
from app.config.qgis_mappings import build_static_layers_config, get_gpkg_dir
# 从磁盘恢复已缓存的模板
cached_count = template_cache.load_persistent_cache()
print(f"[qgis_daemon] QGIS 已启动, 磁盘缓存: {cached_count} 个模板",
file=sys.stderr, flush=True)
total_cached = len(template_cache._cache)
print(f"[qgis_daemon] 就绪: 缓存共{total_cached}个模板",
file=sys.stderr, flush=True)
# ── 请求处理循环 ──
print("[qgis_daemon] 等待请求...", file=sys.stderr, flush=True)
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
request = json.loads(line)
except json.JSONDecodeError as e:
resp = json.dumps({"status": "error", "message": f"JSON 解析失败: {e}"})
sys.stdout.write(resp + "\n")
sys.stdout.flush()
continue
action = request.get("action", "map")
if action == "shutdown":
print("[qgis_daemon] 收到 shutdown,退出", file=sys.stderr, flush=True)
break
if action == "map":
config = request["config"]
models = request["models"]
result_file = request.get("result_file", "")
results = []
t_batch = time.time()
if "static_layers" not in config or not config["static_layers"].get("gpkg_dir"):
config["static_layers"] = build_static_layers_config(get_gpkg_dir())
service = MapService(config)
for i, model in enumerate(models):
t_model = time.time()
try:
name = service.generate(model)
results.append({"name": name, "output": model["outFile"]})
elapsed = time.time() - t_model
print(f"[qgis_daemon] [{i+1}/{len(models)}] {name} ({elapsed:.1f}s)",
file=sys.stderr, flush=True)
except Exception as e:
elapsed = time.time() - t_model
results.append({"name": model.get("name", ""), "output": "", "error": str(e)})
print(f"[qgis_daemon] [{i+1}/{len(models)}] 失败: {e}",
file=sys.stderr, flush=True)
total = time.time() - t_batch
ok = sum(1 for r in results if "error" not in r)
print(f"[qgis_daemon] 完成: {ok}/{len(models)}, {total:.1f}s",
file=sys.stderr, flush=True)
resp = {"results": results}
# 写入结果文件(可靠),然后 stdout 发完成信号
if result_file:
try:
with open(result_file, "w", encoding="utf-8") as f:
json.dump(resp, f, ensure_ascii=False)
except Exception as e:
print(f"[qgis_daemon] 写结果文件失败: {e}", file=sys.stderr, flush=True)
sys.stdout.write("OK\n")
sys.stdout.flush()
# 清理
project = QgsProject.instance()
project.clear()
template_cache.cleanup()
print("[qgis_daemon] 已退出", file=sys.stderr, flush=True)
if __name__ == "__main__":
main()
+3 -5
View File
@@ -75,7 +75,7 @@ class TemplateCache:
logger.info(f" 模板已缓存: {os.path.basename(template_path)} ({len(self._cache)} 个)")
def restore_template(self, template_path: str) -> tuple:
"""从缓存恢复模板(跳过图层解析以加速)"""
"""从缓存恢复模板"""
cached = self._cache.get(template_path)
if not cached:
raise RuntimeError(f"模板未缓存: {template_path}")
@@ -83,10 +83,8 @@ class TemplateCache:
project = QgsProject.instance()
t0 = time.time()
# FlagDontResolveLayers: 跳过数据库连接验证,缓存文件已含正确连接
flags = QgsProject.ReadFlags()
flags |= QgsProject.FlagDontResolveLayers
project.read(cached["file"], flags)
# 大部分图层已 GPKG 本地化,不需要 FlagDontResolveLayers
project.read(cached["file"])
logger.debug(f" 恢复耗时: {time.time() - t0:.1f}s")
return project, cached["texts"], cached["extent"]
+1 -1
View File
@@ -75,7 +75,7 @@ class TemplateModifier:
tmp.close()
datasource_re = re.compile(r"(<datasource>)(.*?)(</datasource>)", re.DOTALL)
table_re = re.compile(r'table="(\w+)"\."(\w+)"')
table_re = re.compile(r'table=(?:"|&quot;)(\w+)(?:"|&quot;)\.(?:"|&quot;)(\w+)(?:"|&quot;)')
with zipfile.ZipFile(template_path, "r") as zin, \
zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zout:
+2
View File
@@ -21,6 +21,8 @@ QGIS_DEFAULTS_MAP_UNIT = "制图单位:西安市应急管理局"
QGIS_EXPORT_DPI = 300
# 批量产图线程池
QGIS_WORKER_THREADS = 4
# 并行子进程数(每进程独立 QGIS 实例)
QGIS_PARALLEL_WORKERS = 4
# 西安市中心经纬度
XIAN_CENTER = [108.948024, 34.263161]