初始化集成qgis

This commit is contained in:
wzy-warehouse
2026-06-19 17:04:03 +08:00
parent 6a03f66b7d
commit b4cce93af0
17 changed files with 1485 additions and 18 deletions
+13
View File
@@ -0,0 +1,13 @@
from app.services.qgis.map_service import MapService
from app.services.qgis.map_exporter import MapExporter
from app.services.qgis.template_modifier import TemplateModifier
from app.services.qgis.template_cache import TemplateCache
from app.services.qgis.layer_filter import LayerFilter
__all__ = [
'MapService',
'MapExporter',
'TemplateModifier',
'TemplateCache',
'LayerFilter',
]
+31
View File
@@ -0,0 +1,31 @@
"""
图层过滤模块。按 event 和 eqqueue_id 筛选要素。
"""
from app.config.paths import get_logger
logger = get_logger("qgis.filter")
EVENT_LAYERS = ["eqcenter", "震中"]
QUEUE_LAYERS = [
"intensity", "intensity_mian",
"dz_ryss", "dz_jjss", "dz_rysw", "dz_jzph", "dz_xzjl",
]
class LayerFilter:
def apply(self, project, model: dict) -> None:
"""对项目中的图层应用过滤条件"""
event = model.get("event", "")
queue_id = model.get("queueId", "")
logger.info(f"图层过滤: event='{event}', queueId='{queue_id}'")
for name in EVENT_LAYERS:
layers = project.mapLayersByName(name)
if layers:
layers[0].setSubsetString(f"event = '{event}'")
for name in QUEUE_LAYERS:
layers = project.mapLayersByName(name)
if layers:
layers[0].setSubsetString(f"eqqueue_id = '{queue_id}'")
+49
View File
@@ -0,0 +1,49 @@
"""
地图导出模块。布局文本更新、比例尺调整、图片导出。
"""
from qgis.core import QgsLayoutExporter, QgsScaleBarSettings
from app.config.paths import get_logger
logger = get_logger("qgis.exporter")
class MapExporter:
def __init__(self, config: dict, layout):
self.config = config
self.layout = layout
def update_texts(self, model: dict) -> None:
"""更新布局中的文本标签"""
for key in ["mapTitle", "mapTime", "mapUnit", "info"]:
label = self.layout.itemById(key)
if label is not None:
label.setText(model[key])
def update_scale_bar(self) -> None:
"""调整比例尺为自适应宽度模式"""
scale_bar = self.layout.itemById("ScaleBar")
if scale_bar is None:
logger.warning("比例尺控件不存在")
return
scale_bar.setSegmentSizeMode(
QgsScaleBarSettings.SegmentSizeMode.SegmentSizeFitWidth
)
scale_bar.setMaximumBarWidth(70)
scale_bar.setMinimumBarWidth(40)
def export(self, path: str) -> None:
"""导出布局为图片"""
dpi = self.config["qgis"]["exportDpi"]
settings = QgsLayoutExporter.ImageExportSettings()
settings.dpi = dpi
exporter = QgsLayoutExporter(self.layout)
result = exporter.exportToImage(path, settings)
if result != QgsLayoutExporter.Success:
raise RuntimeError(f"图片导出失败: {path}")
logger.info(f"图片已导出: {path}")
+135
View File
@@ -0,0 +1,135 @@
"""
地图生成主流程控制器。
协调模板加载、图层过滤、缩放、文本更新、导出。
"""
import os
import time
from qgis.core import QgsProject, QgsDataSourceUri
from .template_cache import TemplateCache
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")
# 全局模板缓存(跨请求复用)
template_cache = TemplateCache()
class MapService:
def __init__(self, config: dict):
self.config = config
def generate(self, model: dict) -> str:
"""
执行完整的地图生成流程。
Args:
model: 包含地图参数的字典
Returns:
地图名称
"""
t_start = time.time()
template_path = model["path"]
project = QgsProject.instance()
is_cache_hit = template_cache.is_loaded(template_path)
# 加载/恢复模板
if is_cache_hit:
logger.info(f"[缓存命中] {os.path.basename(template_path)}")
project, texts, extent = template_cache.restore_template(template_path)
template_cache.reset_project_state(project, texts, extent)
else:
logger.info(f"[首次加载] {os.path.basename(template_path)}")
modifier = TemplateModifier(self.config)
actual_path = modifier.modify(template_path)
template_cache.load_template(actual_path, layout_name=model.get("mapLayout", "A4"))
if actual_path != template_path:
try:
os.remove(actual_path)
except OSError:
pass
# 更新 PostgreSQL 图层连接(仅首次加载)
if not is_cache_hit:
self._update_db_connections(project)
# 图层过滤
LayerFilter().apply(project, model)
# 地图缩放
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"], {
"X": model["centerX"],
"Y": model["centerY"],
"value": model["zoomValue"],
})
# 文本更新 + 比例尺 + 导出
exporter = MapExporter(self.config, layout)
exporter.update_texts(model)
exporter.update_scale_bar()
exporter.export(model["outFile"])
elapsed = time.time() - t_start
logger.info(
f"{'[缓存命中]' if is_cache_hit else '[首次加载]'} "
f"导出完成: {model['name']},耗时 {elapsed:.1f}s"
)
return model["name"]
def _update_db_connections(self, project: QgsProject) -> None:
"""更新所有 PostgreSQL 图层的数据库连接参数"""
db_config = self.config["db"]
override = self.config.get("template_override", {})
actual_schema = override.get("actual", {}).get("schema", "qgis")
static_count = 0
for layer in project.mapLayers().values():
if layer.providerType() == "ogr":
static_count += 1
continue
if layer.providerType() != "postgres":
continue
try:
uri = layer.dataProvider().uri()
uri.setConnection(
db_config["host"],
str(db_config["port"]),
db_config["database"],
db_config["username"],
db_config["password"],
)
# 更新 schematable="base"."xxx" → table="qgis"."xxx"
old_uri = uri.uri()
if f'table="{actual_schema}".' not in old_uri:
new_uri = old_uri.replace('table="base".', f'table="{actual_schema}".')
if new_uri != old_uri:
uri = QgsDataSourceUri(new_uri)
layer.setDataSource(uri.uri(), layer.name(), "postgres")
if layer.isValid():
logger.info(f"图层 {layer.name()} 连接更新成功")
else:
logger.error(f"图层 {layer.name()} 更新后仍无效")
except Exception as e:
logger.error(f"更新图层 {layer.name()} 连接失败: {e}")
if static_count:
logger.info(f"静态底图已本地化: {static_count} 个 GPKG 图层跳过远程连接")
+266
View File
@@ -0,0 +1,266 @@
"""
QGIS 环境初始化模块(跨平台:Windows / Linux)。
在 server.py lifespan 启动阶段调用 init_qgis_env()
完成共享库注入、环境变量设置、QgsApplication 初始化。
"""
import os
import sys
import platform
from pathlib import Path
from app.config.paths import get_logger
logger = get_logger("qgis.env")
_qgs_app = None
_initialized = False
_IS_WINDOWS = platform.system() == "Windows"
def init_qgis_env(qgis_root: str) -> None:
"""
初始化 QGIS 运行环境。整个应用生命周期只需调用一次。
Args:
qgis_root: QGIS 安装根目录
Windows: "D:/QGIS"
Linux: "/usr""/opt/QGIS"
"""
global _qgs_app, _initialized
if _initialized:
return
qgis_app_dir = _find_qgis_app_dir(qgis_root)
# 共享库搜索路径(平台相关)
# Windows: os.add_dll_directory() 显式注册 DLL 目录
# Linux: LD_LIBRARY_PATH 追加 .so 搜索目录
_add_library_paths(qgis_root, qgis_app_dir)
# 环境变量
_set_environment(qgis_root, qgis_app_dir)
# Python 模块路径
_add_python_paths(qgis_root, qgis_app_dir)
# 初始化 QgsApplication
_init_qgs_application(qgis_app_dir)
_initialized = True
logger.info(f"QGIS 环境初始化完成 ({platform.system()})")
def cleanup_qgis_env() -> None:
"""清理 QGIS 资源(应用退出时调用)"""
global _qgs_app, _initialized
if _qgs_app is not None:
_qgs_app.exitQgis()
_qgs_app = None
_initialized = False
logger.info("QGIS 资源已清理")
def is_qgis_ready() -> bool:
"""检查 QGIS 是否已初始化"""
return _initialized
# ─────────────────────────────────────────────────────────────
# 平台检测
# ─────────────────────────────────────────────────────────────
def _find_qgis_app_dir(root: str) -> str:
"""
自动检测 QGIS 应用目录。
WindowsOSGeo4W 安装): {root}/apps/qgis-ltr/
Linux(包管理器安装): {root}/share/qgis/Python 在 {root}/share/qgis/python
"""
if _IS_WINDOWS:
return _find_qgis_app_dir_windows(root)
else:
return _find_qgis_app_dir_linux(root)
def _find_qgis_app_dir_windows(root: str) -> str:
"""Windows: 在 {root}/apps/ 下查找 qgis* 目录"""
apps_dir = Path(root) / "apps"
if apps_dir.is_dir():
for name in sorted(apps_dir.iterdir()):
if name.name.startswith("qgis") and name.is_dir():
logger.info(f"检测到 QGIS 应用目录: {name}")
return str(name)
fallback = str(apps_dir / "qgis")
logger.warning(f"未检测到 qgis* 目录,使用默认路径: {fallback}")
return fallback
def _find_qgis_app_dir_linux(root: str) -> str:
"""
Linux: 返回 QGIS_PREFIX_PATH。
包管理器安装的标准路径:
Debian/Ubuntu: /usr qgis-core 在 /usr/lib/qgis/
RHEL/CentOS: /usr
QGIS.org 官方: /usr 或 /opt/QGIS
"""
candidates = [
Path(root) / "share" / "qgis", # /usr/share/qgis
Path(root) / "lib" / "qgis", # /usr/lib/qgis
Path(root) / "share" / "qgis-ltr", # qgis-ltr 变体
]
for c in candidates:
if c.is_dir():
logger.info(f"检测到 QGIS 应用目录: {c}")
return str(c)
# 回退:用 root 本身(/usr),由 QgsApplication 自行探测
logger.warning(f"未找到 QGIS 标准目录,使用 root: {root}")
return str(root)
# ─────────────────────────────────────────────────────────────
# 共享库注入
# ─────────────────────────────────────────────────────────────
def _add_library_paths(root: str, qgis_app: str) -> None:
"""
注入共享库搜索路径。
Windows: 调用 os.add_dll_directory() 显式注册每个 DLL 目录,
Python 3.8+ 不再自动搜索 PATH 中的 DLL。
Linux: 追加 LD_LIBRARY_PATH,让动态链接器 (ld-linux) 找到 .so。
"""
if _IS_WINDOWS:
_add_dll_directories_windows(root, qgis_app)
else:
_add_ld_library_path_linux(root, qgis_app)
def _add_dll_directories_windows(root: str, qgis_app: str) -> None:
"""Windows: 显式注册 DLL 搜索目录"""
if not hasattr(os, "add_dll_directory"):
return # Python < 3.8
dll_dirs = [
os.path.join(root, "apps", "Qt5", "bin"),
os.path.join(qgis_app, "bin"),
os.path.join(root, "bin"),
os.path.join(root, "apps", "gdal", "bin"),
os.path.join(root, "apps", "gdal", "lib"),
]
for d in dll_dirs:
if os.path.isdir(d):
os.add_dll_directory(d)
logger.debug(f"注册 DLL 目录: {d}")
def _add_ld_library_path_linux(root: str, qgis_app: str) -> None:
"""
Linux: 追加 LD_LIBRARY_PATH,使动态链接器找到 QGIS/GDAL 的 .so。
QGIS 包管理器安装时的标准 .so 路径:
/usr/lib/ — libqgis_core.so, libqgis_analysis.so
/usr/lib/qgis/ — 插件 .so
/usr/lib/qgis/plugins/ — provider .so
/usr/share/qgis/python/ — Python 模块
"""
ld_dirs = [
os.path.join(root, "lib"), # /usr/lib
os.path.join(root, "lib", "qgis"), # /usr/lib/qgis
os.path.join(root, "lib", "qgis", "plugins"), # /usr/lib/qgis/plugins
os.path.join(root, "share", "qgis", "lib"), # 某些安装方式
os.path.join(root, "apps", "gdal", "lib"), # OSGeo4W 风格
]
existing = os.environ.get("LD_LIBRARY_PATH", "")
new_dirs = [d for d in ld_dirs if os.path.isdir(d) and d not in existing]
if new_dirs:
joined = ":".join(new_dirs)
os.environ["LD_LIBRARY_PATH"] = f"{joined}:{existing}" if existing else joined
logger.info(f"LD_LIBRARY_PATH 追加: {new_dirs}")
# ─────────────────────────────────────────────────────────────
# 环境变量
# ─────────────────────────────────────────────────────────────
def _set_environment(root: str, qgis_app: str) -> None:
"""设置 QGIS 和 GDAL 相关环境变量(平台自适应)"""
env_vars = {
"QGIS_PREFIX_PATH": qgis_app,
"PYTHONUTF8": "1",
"GDAL_FILENAME_IS_UTF8": "YES",
"VSI_CACHE": "TRUE",
"VSI_CACHE_SIZE": "1000000",
}
if _IS_WINDOWS:
env_vars["QT_PLUGIN_PATH"] = (
f"{os.path.join(qgis_app, 'qtplugins')};"
f"{os.path.join(root, 'apps', 'Qt5', 'plugins')}"
)
env_vars["GDAL_DATA"] = os.path.join(root, "apps", "gdal", "share", "gdal")
else:
# Linux: GDAL 数据通常在系统路径 /usr/share/gdal/ 下
gdal_candidates = [
os.path.join(root, "share", "gdal"),
os.path.join(root, "share", "qgis", "resources"),
]
for p in gdal_candidates:
if os.path.isdir(p):
env_vars["GDAL_DATA"] = p
break
# QGIS 插件路径(Linux 标准)
qt_plugin = os.path.join(root, "lib", "qt", "plugins")
if os.path.isdir(qt_plugin):
env_vars["QT_PLUGIN_PATH"] = qt_plugin
for key, value in env_vars.items():
os.environ[key] = value
logger.debug(f"环境变量: {key}={value}")
# ─────────────────────────────────────────────────────────────
# Python 模块路径
# ─────────────────────────────────────────────────────────────
def _add_python_paths(root: str, qgis_app: str) -> None:
"""将 QGIS Python 模块路径加入 sys.path(平台自适应)"""
if _IS_WINDOWS:
python_paths = [
os.path.join(qgis_app, "python"),
os.path.join(root, "apps", "Python312", "Lib", "site-packages"),
]
else:
python_paths = [
os.path.join(qgis_app, "python"), # /usr/share/qgis/python
os.path.join(root, "lib", "python3", "dist-packages"), # Debian/Ubuntu
os.path.join(root, "lib", "python3.10", "site-packages"), # 通用
]
for p in python_paths:
if os.path.isdir(p) and p not in sys.path:
sys.path.insert(0, p)
logger.info(f"添加 Python 路径: {p}")
# ─────────────────────────────────────────────────────────────
# QgsApplication 初始化
# ─────────────────────────────────────────────────────────────
def _init_qgs_application(qgis_app: str) -> None:
"""创建并初始化 QgsApplication 实例"""
global _qgs_app
from qgis.core import QgsApplication, QgsSettings
QgsApplication.setPrefixPath(qgis_app, True)
_qgs_app = QgsApplication([], False) # False = 不启动 GUI
settings = QgsSettings()
settings.setValue("/qgis/render_decorations", False)
settings.setValue("/qgis/parallel_rendering", True)
settings.setValue("/qgis/use_spatial_index", True)
_qgs_app.initQgis()
logger.info("QgsApplication 初始化完成")
+120
View File
@@ -0,0 +1,120 @@
"""
模板缓存引擎。解决 QgsProject 单例问题。
流程:
1. 首次请求:project.read() 加载模板(慢,仅一次)
2. 加载后 project.write() 保存到临时文件
3. 后续同模板请求:从临时文件恢复(快,连接复用)
4. 手动恢复文本/过滤/缩放(毫秒级)
"""
import os
import time
import tempfile
from qgis.core import QgsProject, QgsRectangle
from app.config.paths import get_logger
logger = get_logger("qgis.cache")
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()
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=os.path.dirname(template_path),
)
tmp_path = tmp_file.name
tmp_file.close()
t_save = time.time()
project.write(tmp_path)
logger.info(f"项目保存耗时: {time.time() - t_save:.1f}s")
# 记录初始状态
texts = {}
extent = None
layout = project.layoutManager().layoutByName(layout_name)
if layout:
for item_id in ["mapTitle", "mapTime", "mapUnit", "info"]:
item = layout.itemById(item_id)
if item:
texts[item_id] = item.text()
map_item = layout.itemById("Map")
if map_item:
extent = QgsRectangle(map_item.extent())
self._cache[template_path] = {
"file": tmp_path,
"texts": texts,
"extent": extent,
"layout": layout_name,
}
logger.info(f"模板加载完成,总耗时: {time.time() - start:.1f}s")
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()
logger.info(f"恢复模板: {os.path.basename(template_path)}")
project.read(cached["file"])
logger.info(f"project.read() 耗时: {time.time() - start:.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")
break
layout = project.layoutManager().layoutByName(layout_name)
if layout:
for item_id, text in texts.items():
item = layout.itemById(item_id)
if item:
item.setText(text)
if extent:
map_item = layout.itemById("Map")
if map_item:
map_item.zoomToExtent(extent)
logger.info(f"状态重置耗时: {time.time() - start:.3f}s")
def cleanup(self) -> None:
"""清理所有临时文件"""
for cached in self._cache.values():
try:
os.remove(cached["file"])
except OSError:
pass
self._cache.clear()
logger.info("已清理所有缓存")
+111
View File
@@ -0,0 +1,111 @@
"""
模板 XML 修改模块。在 project.read() 之前:
1. 替换 PostgreSQL 连接参数(host/port/dbname/schema
2. 将静态底图的 datasource 替换为本地 GeoPackage 路径
"""
import os
import re
import zipfile
import tempfile
from app.config.paths import get_logger
logger = get_logger("qgis.modifier")
class TemplateModifier:
def __init__(self, config: dict):
self.config = config
self._static_map = self._build_static_map()
def _build_static_map(self) -> dict[str, str]:
"""构建 table_key → gpkg_abs_path 的映射"""
sl = self.config.get("static_layers")
if not sl or not sl.get("enabled", False):
return {}
gpkg_dir = sl["gpkg_dir"]
mapping = {}
for info in sl["layers"].values():
schema, table = info["table"].split(".", 1)
xml_key = f'table="{schema}"."{table}"'
gpkg_path = os.path.join(gpkg_dir, info["file"]).replace("\\", "/")
mapping[xml_key] = gpkg_path
return mapping
def modify(self, template_path: str) -> str:
"""修改模板文件,返回修改后的临时 .qgz 路径"""
override = self.config.get("template_override")
has_override = override and override.get("enabled", False)
has_static = bool(self._static_map)
if not has_override and not has_static:
return template_path
orig = override["original"] if has_override else None
actual = override["actual"] if has_override else None
try:
tmp = tempfile.NamedTemporaryFile(
suffix=".qgz", delete=False,
dir=os.path.dirname(template_path),
)
tmp_path = tmp.name
tmp.close()
datasource_re = re.compile(r"(<datasource>)(.*?)(</datasource>)", re.DOTALL)
table_re = re.compile(r'table="(\w+)"\."(\w+)"')
with zipfile.ZipFile(template_path, "r") as zin, \
zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
data = zin.read(item.filename)
if item.filename.endswith(".qgs"):
content = data.decode("utf-8")
# 替换 host/port/dbname/schema
if orig and actual:
content = re.sub(
rf"(host\s*=\s*['\"])({re.escape(orig['host'])})(['\"])",
rf"\g<1>{actual['host']}\3",
content,
)
content = re.sub(
rf"(port\s*=\s*['\"])({re.escape(str(orig['port']))})(['\"])",
rf"\g<1>{actual['port']}\3",
content,
)
content = re.sub(
rf"(dbname\s*=\s*['\"])({re.escape(orig['dbname'])})(['\"])",
rf"\g<1>{actual['dbname']}\3",
content,
)
content = content.replace(
f'table="{orig["schema"]}".',
f'table="{actual["schema"]}".',
)
# 替换静态底图 datasource
if self._static_map:
def _replace(m):
tbl = table_re.search(m.group(2))
if tbl:
key = f'table="{tbl.group(1)}"."{tbl.group(2)}"'
if key in self._static_map:
return f"{m.group(1)}{self._static_map[key]}{m.group(3)}"
return m.group(0)
content = datasource_re.sub(_replace, content)
data = content.encode("utf-8")
zout.writestr(item, data)
logger.info(f"模板已修改并写入: {tmp_path}")
return tmp_path
except Exception as e:
logger.error(f"模板修改失败,将使用原始模板: {e}")
return template_path