QGIS提升速度

This commit is contained in:
wzy-warehouse
2026-06-21 13:29:19 +08:00
parent fe3ccd005d
commit 5169ed2f33
28 changed files with 444 additions and 394 deletions
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.
+244
View File
@@ -0,0 +1,244 @@
{
"templates": {
"F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨内涝潜在隐患点及人口分布图.qgz": {
"file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\4c40d3feeddf4567.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\\19470628504c856d.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\\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\\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\\66a88abaa976d72a.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\\3bac2f2636b197e9.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\\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",
"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\\98c5467c5904eee5.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",
"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",
"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\\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
}
}
}
}
+78 -57
View File
@@ -14,7 +14,6 @@ from .template_modifier import TemplateModifier
from .layer_filter import LayerFilter
from .map_exporter import MapExporter
from app.utils.map_zoom import MapZoom
from app.config.paths import get_logger
logger = get_logger("qgis.service")
@@ -29,43 +28,58 @@ class MapService:
def generate(self, model: dict) -> str:
"""
执行完整的地图生成流程。
Args:
model: 包含地图参数的字典
Returns:
地图名称
"""
t_start = time.time()
_timing = {} # {step: seconds}
t_total = time.time()
template_path = model["path"]
template_name = os.path.basename(template_path)
project = QgsProject.instance()
is_cache_hit = template_cache.is_loaded(template_path)
# 加载/恢复模板
# ── 步骤 1加载/恢复模板 ──
t0 = time.time()
if is_cache_hit:
logger.info(f"[缓存命中] {os.path.basename(template_path)}")
logger.info(f"[缓存命中] {template_name}")
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"[首次加载] {os.path.basename(template_path)}")
# 直接读原始模板,不做文件级修改(避免 ZIP 兼容性问题)
project.read(template_path)
logger.info(f"[首次加载] {template_name}")
modifier = TemplateModifier(self.config)
actual_path = modifier.modify(template_path)
project.read(actual_path)
if actual_path != template_path:
try:
os.remove(actual_path)
except OSError:
pass
_timing["1.load"] = time.time() - t0
# 更新图层连接 + GPKG/SRID/表名修正(仅首次加载)
# ── 步骤 2:图层连接修正 ──
t0 = time.time()
if not is_cache_hit:
self._update_db_connections(project)
self._fix_invalid_layers(project)
_timing["2.connections"] = time.time() - t0
# 图层过滤
# ★ 首次加载完成后再保存缓存(确保图层已修正)
if not is_cache_hit:
template_cache.save_current(template_path, model.get("mapLayout", "A3"))
# ── 步骤 3:图层过滤 ──
t0 = time.time()
LayerFilter().apply(project, model)
_timing["3.filter"] = time.time() - t0
# 地图缩放
# ── 步骤 4地图缩放 ──
t0 = time.time()
layout = project.layoutManager().layoutByName(model["mapLayout"])
if layout is None:
available = [l.name() for l in project.layoutManager().layouts()]
raise RuntimeError(
f"模板中未找到布局 '{model['mapLayout']}',可用布局:{available}"
)
map_item = layout.itemById("Map")
zoom = MapZoom(project, layout, map_item)
zoom.execute(model["zoomRule"], {
@@ -73,61 +87,69 @@ class MapService:
"Y": model["centerY"],
"value": model["zoomValue"],
})
_timing["4.zoom"] = time.time() - t0
# 文本更新 + 比例尺 + 导出
# ── 步骤 5文本 + 比例尺 + 导出 ──
t0 = time.time()
exporter = MapExporter(self.config, layout)
exporter.update_texts(model)
exporter.update_scale_bar()
exporter.export(model["outFile"])
_timing["5.export"] = time.time() - t0
elapsed = time.time() - t_start
total = time.time() - t_total
steps = ", ".join(f"{k}={v:.1f}s" for k, v in _timing.items())
logger.info(
f"{'[缓存命中]' if is_cache_hit else '[首次加载]'} "
f"导出完成: {model['name']},耗时 {elapsed:.1f}s"
f"{template_name} {total:.1f}s ({steps})"
)
return model["name"]
def _update_db_connections(self, project: QgsProject) -> None:
"""更新图层连接 + SRID修正 + GPKG静态层替换"""
def _fix_invalid_layers(self, project: QgsProject) -> None:
"""只修复 TemplateModifier 处理后仍无效的图层(TemplateModifier 已修正大部分连接)"""
t0 = time.time()
db_config = self.config["db"]
override = self.config.get("template_override", {})
actual_schema = override.get("actual", {}).get("schema", "qgis")
static_config = self.config.get("static_layers", {})
static_enabled = static_config.get("enabled", False)
gpkg_dir = static_config.get("gpkg_dir", "")
static_layers_map = static_config.get("layers", {})
static_count = 0
fixed = 0
failed = 0
total = 0
for layer in project.mapLayers().values():
provider = layer.providerType()
# GPKG 静态层替换:postgres 表 → 本地 GPKG 文件
if provider == "postgres" and static_enabled:
uri_str = layer.dataProvider().uri().uri()
for name, info in static_layers_map.items():
table_key = info["table"]
schema, table = table_key.split(".", 1)
if f'table="{schema}"."{table}"' in uri_str:
gpkg_path = os.path.join(gpkg_dir, info["file"]).replace("\\", "/")
layer.setDataSource(gpkg_path, name, "ogr")
static_count += 1
logger.debug(f"静态图层 {name} → GPKG")
break
if layer.providerType() != "postgres":
continue
total += 1
if layer.isValid():
continue # TemplateModifier 已修正,跳过
try:
self._fix_postgres_layer(layer, db_config, actual_schema)
if layer.isValid():
fixed += 1
else:
# 没匹配到静态层,继续处理为 postgres
self._fix_postgres_layer(layer, db_config, actual_schema)
failed += 1
except Exception as e:
logger.error(f" 修复图层 {layer.name()} 失败: {e}")
failed += 1
elapsed = time.time() - t0
if total:
logger.info(
f" 图层修正: 总计{total}个PG层, 跳过{total - fixed - failed}(已有效), "
f"修复{fixed}, 仍失败{failed}, 耗时{elapsed:.1f}s"
)
@staticmethod
def _resolve_postgres_layers(project: QgsProject) -> None:
"""FlagDontResolveLayers 跳过了数据库连接,重新 setDataSource 触发"""
for layer in project.mapLayers().values():
if layer.providerType() != "postgres":
continue
if provider == "ogr":
static_count += 1
continue
if provider == "postgres":
self._fix_postgres_layer(layer, db_config, actual_schema)
if static_count:
logger.info(f"静态底图已本地化: {static_count} 个图层")
try:
source = layer.source()
if source:
layer.setDataSource(source, layer.name(), "postgres")
except Exception:
pass
@staticmethod
def _fix_postgres_layer(layer, db_config, actual_schema):
@@ -168,8 +190,7 @@ class MapService:
layer.setDataSource(uri.uri(), layer.name(), "postgres")
if layer.isValid():
fc = layer.featureCount()
logger.info(f"图层 {layer.name()} 连接更新成功 ({fc} features)")
logger.debug(f" 图层 {layer.name()} 连接更新成功")
else:
logger.error(f" 图层 {layer.name()} 更新后仍无效")
except Exception as e:
+6
View File
@@ -138,6 +138,12 @@ def main():
# 初始化 QgsApplication(只做一次)
qgs_app = _init_qgis()
# 从磁盘恢复已缓存的模板(跨进程加速)
from app.services.qgis.map_service import template_cache
cached_count = template_cache.load_persistent_cache()
if cached_count > 0:
print(f"[qgis_runner] 磁盘缓存命中: {cached_count} 个模板", file=sys.stderr)
try:
from app.services.qgis.map_service import MapService
+115 -41
View File
@@ -1,13 +1,15 @@
"""
模板缓存引擎。解决 QgsProject 单例问题
模板缓存引擎(支持持久化)
流程:
1. 首次请求:project.read() 加载模板(慢,仅一次)
2. 加载后 project.write() 保存到临时文件
3. 后续同模板请求:从临时文件恢复(快,连接复用
4. 手动恢复文本/过滤/缩放(毫秒级)
1. 首次加载:TemplateModifier 修改模板 → project.read() → 保存到磁盘缓存
2. 同进程内缓存命中:从内存恢复(~1s
3. 跨进程/重启:从磁盘缓存恢复(首次 ~5s,后续 ~1s
"""
import hashlib
import json
import os
import shutil
import time
import tempfile
@@ -17,36 +19,37 @@ from app.config.paths import get_logger
logger = get_logger("qgis.cache")
# 持久化缓存目录(项目根目录下)
_CACHE_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))),
"app", "data", "cache", "qgis_templates",
)
_INDEX_FILE = os.path.join(_CACHE_DIR, "index.json")
class TemplateCache:
def __init__(self):
self._cache: dict[str, dict] = {}
# ── 公开接口 ──
def is_loaded(self, template_path: str) -> bool:
return template_path in self._cache
def load_template(self, template_path: str, layout_name: str = "A4") -> None:
"""首次加载模板"""
start = time.time()
def save_current(self, template_path: str, layout_name: str = "A4") -> None:
"""将当前已加载的项目保存到内存缓存 + 磁盘缓存"""
project = QgsProject.instance()
logger.info(f"首次加载: {os.path.basename(template_path)}")
project.read(template_path)
logger.info(f"project.read() 耗时: {time.time() - start:.1f}s")
# 保存到临时文件
tmp_file = tempfile.NamedTemporaryFile(
suffix=".qgz", delete=False,
dir=tempfile.gettempdir(),
)
tmp_path = tmp_file.name
tmp_file.close()
# ── 保存项目到持久化缓存目录 ──
os.makedirs(_CACHE_DIR, exist_ok=True)
key = self._path_key(template_path)
cache_file = os.path.join(_CACHE_DIR, f"{key}.qgz")
t_save = time.time()
project.write(tmp_path)
logger.info(f"项目保存耗时: {time.time() - t_save:.1f}s")
project.write(cache_file)
logger.info(f" 缓存写入磁盘: {os.path.basename(cache_file)} ({time.time() - t_save:.1f}s)")
# 记录初始状态
# ── 记录状态 ──
texts = {}
extent = None
layout = project.layoutManager().layoutByName(layout_name)
@@ -59,38 +62,41 @@ class TemplateCache:
if map_item:
extent = QgsRectangle(map_item.extent())
self._cache[template_path] = {
"file": tmp_path,
entry = {
"file": cache_file,
"texts": texts,
"extent": extent,
"layout": layout_name,
}
logger.info(f"模板加载完成,总耗时: {time.time() - start:.1f}s")
self._cache[template_path] = entry
# ── 更新索引文件 ──
self._update_index(template_path, entry)
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}")
start = time.time()
project = QgsProject.instance()
t0 = time.time()
logger.info(f"恢复模板: {os.path.basename(template_path)}")
project.read(cached["file"])
logger.info(f"project.read() 耗时: {time.time() - start:.1f}s")
# FlagDontResolveLayers: 跳过数据库连接验证,缓存文件已含正确连接
flags = QgsProject.ReadFlags()
flags |= QgsProject.FlagDontResolveLayers
project.read(cached["file"], flags)
logger.debug(f" 恢复耗时: {time.time() - t0:.1f}s")
return project, cached["texts"], cached["extent"]
def reset_project_state(self, project: QgsProject, texts: dict, extent) -> None:
"""重置项目到干净状态"""
start = time.time()
for layer in project.mapLayers().values():
if layer.subsetString():
layer.setSubsetString("")
# 获取 layout 名称(从缓存中)
layout_name = "A4"
for cached in self._cache.values():
layout_name = cached.get("layout", "A4")
@@ -107,14 +113,82 @@ class TemplateCache:
if map_item:
map_item.zoomToExtent(extent)
logger.info(f"状态重置耗时: {time.time() - start:.3f}s")
def load_persistent_cache(self) -> int:
"""从磁盘恢复所有缓存到内存(qgis_runner 启动时调用)"""
if not os.path.isfile(_INDEX_FILE):
logger.info(" 磁盘缓存为空,首次启动需要加载模板")
return 0
try:
with open(_INDEX_FILE, "r", encoding="utf-8") as f:
index = json.load(f)
except (json.JSONDecodeError, OSError) as e:
logger.warning(f" 缓存索引损坏: {e}")
return 0
count = 0
for template_path, entry in index.get("templates", {}).items():
cache_file = entry.get("file", "")
if not cache_file or not os.path.isfile(cache_file):
logger.debug(f" 跳过无效缓存: {os.path.basename(template_path)}")
continue
texts = entry.get("texts", {})
extent = entry.get("extent")
# 反序列化 extent
if extent and isinstance(extent, dict):
extent = QgsRectangle(
extent.get("xmin", 0), extent.get("ymin", 0),
extent.get("xmax", 0), extent.get("ymax", 0),
)
else:
extent = None
self._cache[template_path] = {
"file": cache_file,
"texts": texts,
"extent": extent,
"layout": entry.get("layout", "A4"),
}
count += 1
logger.info(f" 磁盘缓存加载: {count} 个模板")
return count
def cleanup(self) -> None:
"""清理所有临时文件"""
for cached in self._cache.values():
try:
os.remove(cached["file"])
except OSError:
pass
"""清理内存缓存(磁盘缓存保留)"""
self._cache.clear()
logger.info("已清理所有缓存")
logger.info("已清理内存缓存")
# ── 内部方法 ──
@staticmethod
def _path_key(template_path: str) -> str:
"""模板路径 → 缓存文件名 key"""
return hashlib.md5(template_path.encode()).hexdigest()[:16]
def _update_index(self, template_path: str, entry: dict) -> None:
"""更新磁盘索引文件"""
index = {}
if os.path.isfile(_INDEX_FILE):
try:
with open(_INDEX_FILE, "r", encoding="utf-8") as f:
index = json.load(f)
except (json.JSONDecodeError, OSError):
pass
index.setdefault("templates", {})[template_path] = {
"file": entry["file"],
"layout": entry.get("layout", "A4"),
"texts": entry.get("texts", {}),
"extent": (
{
"xmin": entry["extent"].xMinimum(),
"ymin": entry["extent"].yMinimum(),
"xmax": entry["extent"].xMaximum(),
"ymax": entry["extent"].yMaximum(),
}
if entry.get("extent") else None
),
}
with open(_INDEX_FILE, "w", encoding="utf-8") as f:
json.dump(index, f, ensure_ascii=False, indent=2)
-295
View File
@@ -1,295 +0,0 @@
"""
单张专题图测试脚本 - 独立版
直接构建 model/config 并通过 subprocess 调用 qgis_runner.py
"""
import json, os, sys, tempfile, subprocess, re
from datetime import datetime
# ============================================================
# 配置(和 settings.toml 一致)
# ============================================================
DB_CONFIG = {
"host": "47.92.216.173",
"port": 7654,
"username": "postgres",
"password": "zhangsan",
"database": "xian_new",
}
QGIS_ROOT = "D:/QGIS"
XIAN_CENTER = [108.948024, 34.263161]
GPKG_DIR = r"F:\project\xian\xian_algorithm_new\app\data\gpkg".replace("\\", "/")
TEMPLATE_BASE = r"F:\project\xian\xian_algorithm_new\app\data\template"
# ============================================================
# 模拟推理结果(inference_id=50
# ============================================================
# 从实际数据库查询得知:
# event_type='rainfall', condition={'region_code':'610116','rainfall':'120'},
# occurred_time='2025-09-16 20:00:00'
inference = {
"id": 50,
"event_type": "rainfall",
"condition": {"region_code": "610116", "rainfall": "120"},
"occurred_time": datetime(2025, 9, 16, 20, 0, 0),
}
# ============================================================
# 区域映射(从 settings.toml
# ============================================================
AREA_MAP = {
"610102": "新城区", "610103": "碑林区", "610104": "莲湖区",
"610111": "灞桥区", "610112": "未央区", "610113": "雁塔区",
"610114": "阎良区", "610115": "临潼区", "610116": "长安区",
"610117": "高陵区", "610118": "鄠邑区", "610122": "蓝田县",
"610124": "周至县",
}
STATIC_LAYERS = {
"水库": ("base.rivers", "rivers.gpkg"),
"市州驻地": ("base.sx_capital", "sx_capital.gpkg"),
"河流": ("base.river", "river.gpkg"),
"active_fault": ("base.active_fault", "active_fault.gpkg"),
"陕西省": ("base.sx", "sx.gpkg"),
"乡镇驻地": ("base.sx_street", "sx_street.gpkg"),
"区县驻地": ("base.sx_xa_county", "sx_xa_county.gpkg"),
"县界": ("base.sx_xa_county_boundary", "sx_xa_county_boundary.gpkg"),
"周边区县": ("base.sx_zb_county_boundary", "sx_zb_county_boundary.gpkg"),
"周边市州": ("base.sx_zb_city", "sx_zb_city.gpkg"),
"周边县区": ("base.sx_zb_county", "sx_zb_county.gpkg"),
"traffic_expressway": ("base.traffic_expressway", "traffic_expressway.gpkg"),
"traffic_provincial": ("base.traffic_provincial", "traffic_provincial.gpkg"),
"traffic_railway": ("base.traffic_railway", "traffic_railway.gpkg"),
"traffic_township": ("base.traffic_township", "traffic_township.gpkg"),
"traffic_trunk_line": ("base.traffic_trunk_line", "traffic_trunk_line.gpkg"),
}
# ============================================================
# 辅助函数(从 qgis_map_export.py 直接复制)
# ============================================================
def format_disaster_time(occurred_time):
if isinstance(occurred_time, datetime):
return occurred_time.strftime("%Y%m%d%H%M%S")
return str(occurred_time).replace("-", "").replace(":", "").replace(" ", "")[:14]
def resolve_district(condition):
code = condition.get("region_code")
if code and str(code) in AREA_MAP:
return AREA_MAP[str(code)]
return ""
def build_map_title(event_type, condition, template_name):
district = resolve_district(condition)
prefix = f"陕西西安{district}" if district else "陕西西安"
if event_type == "rainfall":
rainfall = condition.get("rainfall")
if rainfall is not None and rainfall != "":
return f"{prefix}{float(rainfall)}mm{template_name}"
return f"{prefix}{template_name}"
return f"{prefix}{template_name}"
def build_info_text(event_type, condition, occurred_time):
"""构建信息面板文本(不显示灾害类型标签)"""
lines = []
if isinstance(occurred_time, datetime):
time_str = f"{occurred_time.year}{occurred_time.month:02d}{occurred_time.day:02d}{occurred_time.hour:02d}{occurred_time.minute:02d}"
elif occurred_time:
time_str = str(occurred_time)
else:
time_str = ""
lines.append(f"时间:{time_str}")
if event_type == "rainfall":
rainfall = condition.get("rainfall")
if rainfall is not None and rainfall != "":
lines.append(f"累计降雨量:{float(rainfall)}mm")
duration = condition.get("duration")
if duration is not None and duration != "":
lines.append(f"已持续:{duration}")
elif event_type == "earthquake":
magnitude = condition.get("magnitude")
if magnitude is not None and magnitude != "":
lines.append(f"震级:{float(magnitude)}")
lon = condition.get("epicenter_lon")
lat = condition.get("epicenter_lat")
if lon is not None and lat is not None:
lines.append(f"位置:经度{float(lon)}°, 纬度{float(lat)}°")
return "\n".join(lines)
def derive_model_params(inference, batch_folder, template_path):
event_type = inference["event_type"]
condition = inference["condition"]
occurred_time = inference["occurred_time"]
template_name = os.path.splitext(os.path.basename(template_path))[0]
map_title = build_map_title(event_type, condition, template_name)
safe_name = re.sub(r'[\\/:*?"<>|]', '_', template_name)
out_file = os.path.join(batch_folder, f"{safe_name}.jpg").replace("\\", "/")
if isinstance(occurred_time, datetime):
map_time = occurred_time.strftime("%Y-%m-%d %H:%M")
else:
map_time = str(occurred_time)
info_text = build_info_text(event_type, condition, occurred_time)
center_x, center_y = XIAN_CENTER
if event_type == "earthquake":
lon = condition.get("epicenter_lon", XIAN_CENTER[0])
lat = condition.get("epicenter_lat", XIAN_CENTER[1])
center_x, center_y = float(lon), float(lat)
return {
"name": f"test_{inference['id']}_{safe_name}",
"path": template_path,
"outFile": out_file,
"mapLayout": "A3",
"mapTitle": map_title,
"mapTime": map_time,
"mapUnit": "西安市应急管理局",
"info": info_text,
"centerX": center_x,
"centerY": center_y,
"event": str(inference["id"]),
"queueId": str(inference["id"]),
"zoomRule": "11",
"zoomValue": "50",
}
def build_qgis_config(batch_folder):
static_layers_config = {}
for name, (table, gpkg_file) in STATIC_LAYERS.items():
static_layers_config[name] = {"file": gpkg_file, "table": table}
return {
"db": DB_CONFIG,
"qgis": {"exportDpi": 300},
"template_override": {
"enabled": True,
"original": {
"host": "localhost",
"port": 5432,
"dbname": "yjzyk_xian",
"schema": "base",
},
"actual": {
"host": DB_CONFIG["host"],
"port": DB_CONFIG["port"],
"dbname": DB_CONFIG["database"],
"schema": "qgis",
"username": DB_CONFIG["username"],
"password": DB_CONFIG["password"],
},
},
"static_layers": {
"enabled": True,
"gpkg_dir": GPKG_DIR,
"layers": static_layers_config,
},
"batch_folder": batch_folder,
}
# ============================================================
# 主逻辑
# ============================================================
event_type = inference["event_type"]
disaster_time = format_disaster_time(inference["occurred_time"])
batch_folder = os.path.join("G:/files", "xian/qgis/map", disaster_time).replace("\\", "/")
os.makedirs(batch_folder, exist_ok=True)
template_dir = os.path.join(TEMPLATE_BASE, event_type)
template_path = os.path.join(template_dir, "暴雨内涝潜在隐患点及人口分布图.qgz").replace("\\", "/")
model = derive_model_params(inference, batch_folder, template_path)
config = build_qgis_config(batch_folder)
print("=" * 60)
print(f"模板: {template_path}")
print(f"输出: {model['outFile']}")
print(f"标题: {model['mapTitle']}")
print(f"时间: {model['mapTime']}")
print(f"单位: {model['mapUnit']}")
print(f"info:\n{model['info']}")
print(f"中心: ({model['centerX']}, {model['centerY']})")
print(f"event: {model['event']}, queueId: {model['queueId']}")
print("=" * 60)
request_data = json.dumps(
{"config": config, "model": model},
ensure_ascii=False,
)
tmp_json = tempfile.NamedTemporaryFile(
suffix=".json", delete=False, mode="w", encoding="utf-8"
)
tmp_json.write(request_data)
tmp_json.close()
runner_script = os.path.join(
os.path.dirname(__file__),
r"F:\project\xian\xian_algorithm_new\app\services\qgis\qgis_runner.py"
).replace("\\", "/")
# Fix: use absolute path
runner_script = r"F:\project\xian\xian_algorithm_new\app\services\qgis\qgis_runner.py"
bat_dir = os.path.join(tempfile.gettempdir(), "qgis_runner")
os.makedirs(bat_dir, exist_ok=True)
bat_path = os.path.join(bat_dir, "run_qgis_test.bat")
qgis_app_dir = os.path.join(QGIS_ROOT, "apps", "qgis-ltr").replace("/", "\\")
python_dir = os.path.join(QGIS_ROOT, "apps", "Python312").replace("/", "\\")
qt5_plugins = os.path.join(QGIS_ROOT, "apps", "Qt5", "plugins").replace("/", "\\")
qtplugins = os.path.join(qgis_app_dir, "qtplugins").replace("/", "\\")
gdal_data = os.path.join(QGIS_ROOT, "apps", "gdal", "share", "gdal").replace("/", "\\")
qgis_python_dir = os.path.join(qgis_app_dir, "python").replace("/", "\\")
qgis_bin = os.path.join(qgis_app_dir, "bin").replace("/", "\\")
qt5_bin = os.path.join(QGIS_ROOT, "apps", "Qt5", "bin").replace("/", "\\")
gdal_lib = os.path.join(QGIS_ROOT, "apps", "gdal", "lib").replace("/", "\\")
python_exe = os.path.join(python_dir, "python3.exe").replace("/", "\\")
bat_content = f"""@echo off
set "PYTHONHOME={python_dir}"
set "PYTHONPATH={qgis_python_dir}"
set "QGIS_PREFIX_PATH={qgis_app_dir}"
set "QT_PLUGIN_PATH={qtplugins};{qt5_plugins}"
set "GDAL_DATA={gdal_data}"
set "PYTHONUTF8=1"
set "GDAL_FILENAME_IS_UTF8=YES"
set "VSI_CACHE=TRUE"
set "VSI_CACHE_SIZE=1000000"
set "PATH={qgis_bin};{qt5_bin};{gdal_lib};%PATH%"
"{python_exe}" "{runner_script}" "{tmp_json.name}"
"""
with open(bat_path, "w", encoding="utf-8") as f:
f.write(bat_content)
cmd = ["cmd.exe", "/c", bat_path]
print(f"执行: {' '.join(cmd[:3])} ...")
print()
try:
result = subprocess.run(cmd, capture_output=True, timeout=300)
stderr_text = result.stderr.decode("utf-8", errors="replace")
stdout_text = result.stdout.decode("utf-8", errors="replace")
print("=== stderr ===")
for line in stderr_text.split("\n"):
print(f" {line}")
print()
print("=== stdout ===")
print(stdout_text[:500] if stdout_text else "(empty)")
if result.returncode != 0:
print(f"\n!!! FAIL: exit={result.returncode}")
else:
print(f"\n=== SUCCESS ===")
if os.path.isfile(model["outFile"]):
print(f"文件: {model['outFile']} ({os.path.getsize(model['outFile'])/1024:.1f} KB)")
else:
print(f"文件不存在: {model['outFile']}")
finally:
try:
os.remove(tmp_json.name)
except OSError:
pass