From b4cce93af0f13e43902db5ee38cafee424bb5c0e Mon Sep 17 00:00:00 2001 From: wzy-warehouse <18135009705@163.com> Date: Fri, 19 Jun 2026 17:04:03 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E9=9B=86=E6=88=90qg?= =?UTF-8?q?is?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/__init__.py | 2 + app/api/qgis_map_export.py | 385 +++++++++++++++++++++++++ app/core/server.py | 25 +- app/repositories/__init__.py | 3 +- app/repositories/qgis_repository.py | 31 ++ app/schemas/api_schemas.py | 15 + app/script/export_static_layers.py | 80 +++++ app/services/qgis/__init__.py | 13 + app/services/qgis/layer_filter.py | 31 ++ app/services/qgis/map_exporter.py | 49 ++++ app/services/qgis/map_service.py | 135 +++++++++ app/services/qgis/qgis_env.py | 266 +++++++++++++++++ app/services/qgis/template_cache.py | 120 ++++++++ app/services/qgis/template_modifier.py | 111 +++++++ app/utils/map_zoom.py | 123 ++++++++ requirements.txt | 3 +- settings.toml | 111 ++++++- 17 files changed, 1485 insertions(+), 18 deletions(-) create mode 100644 app/api/qgis_map_export.py create mode 100644 app/repositories/qgis_repository.py create mode 100644 app/script/export_static_layers.py create mode 100644 app/services/qgis/__init__.py create mode 100644 app/services/qgis/layer_filter.py create mode 100644 app/services/qgis/map_exporter.py create mode 100644 app/services/qgis/map_service.py create mode 100644 app/services/qgis/qgis_env.py create mode 100644 app/services/qgis/template_cache.py create mode 100644 app/services/qgis/template_modifier.py create mode 100644 app/utils/map_zoom.py diff --git a/app/api/__init__.py b/app/api/__init__.py index 2ab7859..a761eb4 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -8,6 +8,8 @@ def register_routers(application: FastAPI): """注册所有路由""" from app.api.rainfall import router as rainfall_router from app.api.earthquake import router as earthquake_router + from app.api.qgis_map_export import router as qgis_router application.include_router(rainfall_router) application.include_router(earthquake_router) + application.include_router(qgis_router) diff --git a/app/api/qgis_map_export.py b/app/api/qgis_map_export.py new file mode 100644 index 0000000..ea49d8f --- /dev/null +++ b/app/api/qgis_map_export.py @@ -0,0 +1,385 @@ +""" +QGIS 专题图导出接口 +- 查询该记录的 occurred_time,格式化为时间戳(如 20260619143000)作为文件夹名 +- 同一 occurred_time 视为同一场灾害,共享文件夹,只产图一次 +- 线程池并发产图(默认 4 worker) +- 输出:FILE_STORE_DIR/xian/qgis/map/{disasterTime}/{模板名称}.jpg +- 标题格式:陕西西安{区县名称}{震级/降雨量}{模板名称} +""" +import asyncio +import concurrent.futures +import os +import re +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from app.config.paths import get_logger +from app.repositories import qgis_repository +from app.schemas.api_schemas import QgisMapExportResponse, QgisMapExportRequest +from app.utils.api_deps import get_prediction_semaphore +from app.utils.db_helper import db_helper +from config import settings + +logger = get_logger("api.qgis") + +router = APIRouter(prefix="/qgis", tags=["专题图导出"]) + +# 线程池(按配置初始化 worker 数量) +_worker_threads = getattr(settings, "QGIS_WORKER_THREADS", 4) +_thread_pool = concurrent.futures.ThreadPoolExecutor( + max_workers=_worker_threads, + thread_name_prefix="qgis-worker", +) +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() + +# 西安市中心 +xian_center = getattr( settings, "XIAN_CENTER", [108.948024, 34.263161]) + + +# ============================================================ +# 时间戳格式化 +# ============================================================ + +def format_disaster_time(occurred_time) -> str: + """将 occurred_time 格式化为时间戳字符串(YYYYMMDDHHmmss),作为文件夹名""" + if isinstance(occurred_time, datetime): + return occurred_time.strftime("%Y%m%d%H%M%S") + elif occurred_time: + return str(occurred_time).replace("-", "").replace(":", "").replace(" ", "")[:14] + else: + return datetime.now().strftime("%Y%m%d%H%M%S") + + +# ============================================================ +# region_code → 区县名称映射 +# ============================================================ + +REGION_CODE_MAP = settings.area.to_dict() + + +def _resolve_district(condition: dict) -> str: + """从 condition.region_code 映射区县名称""" + code = condition.get("region_code") + if code and str(code) in REGION_CODE_MAP: + return REGION_CODE_MAP[str(code)] + return "" + + +def _build_map_title(event_type: str, condition: dict, template_name: str) -> str: + """ + 构建地图标题。 + + 格式:陕西西安{区县名称}{震级/降雨量}{模板名称} + - 地震:陕西西安临潼区5.1级地震专题图 + - 暴雨(有降雨量):陕西西安长安区120mm降雨专题图 + - 暴雨(无降雨量):陕西西安降雨专题图 + """ + district = _resolve_district(condition) + prefix = f"陕西西安{district}" if district else "陕西西安" + + if event_type == "earthquake": + magnitude = condition.get("magnitude") + if magnitude is not None: + return f"{prefix}{float(magnitude)}级{template_name}" + return f"{prefix}{template_name}" + + elif 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 _derive_model_params( + inference: dict, batch_folder: str, out_filename: str +) -> dict: + """从推理结果 + 配置文件推导专题图生成所需的全部参数。""" + event_type = inference["event_type"] + condition = inference["condition"] + occurred_time = inference["occurred_time"] + + map_layout = getattr(settings, "QGIS_DEFAULTS_MAP_LAYOUT", "A3") + zoom_rule = getattr(settings, "QGIS_DEFAULTS_ZOOM_RULE", "11") + zoom_value = getattr(settings, "QGIS_DEFAULTS_ZOOM_VALUE", "50") + map_unit = getattr(settings, "QGIS_DEFAULTS_MAP_UNIT", "西安市应急管理局") + template_base = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "app", "data", "template" + ) + + template_path = os.path.join(template_base, event_type, f"{map_layout}.qgz") + + template_name_map = {"earthquake": "地震专题图", "rainfall": "降雨专题图"} + template_name = template_name_map.get(event_type, f"{event_type}专题图") + + 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 occurred_time and isinstance(occurred_time, datetime): + map_time = occurred_time.strftime("%Y-%m-%d %H:%M") + elif occurred_time: + map_time = str(occurred_time) + else: + map_time = datetime.now().strftime("%Y-%m-%d %H:%M") + + center_x, center_y = _extract_center_from_condition( + event_type, condition + ) + + return { + "name": f"inference_{inference['id']}", + "path": template_path, + "outFile": out_file, + "mapLayout": map_layout, + "mapTitle": map_title, + "mapTime": map_time, + "mapUint": map_unit, + "info": "", + "centerX": center_x, + "centerY": center_y, + "event": str(inference["id"]), + "queueId": str(inference["id"]), + "zoomRule": zoom_rule, + "zoomValue": zoom_value, + } + + +def _extract_center_from_condition(event_type: str, condition: dict) -> tuple: + """从 condition 提取地图中心坐标""" + if event_type == "earthquake": + lon = condition.get("epicenter_lon", xian_center[0]) + lat = condition.get("epicenter_lat", xian_center[1]) + return float(lon), float(lat) + else: + return xian_center[0], xian_center[1] + + +# ============================================================ +# 构建 QGIS 服务配置字典 +# ============================================================ + +def _build_qgis_config(batch_folder: str) -> dict: + """构建 QGIS 服务配置(含批次输出目录)""" + gpkg_subdir = getattr(settings, "QGIS_GPKG_DIR", "app/data/gpkg") + project_root = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + gpkg_dir = os.path.join(project_root, gpkg_subdir).replace("\\", "/") + + return { + "db": { + "host": settings.DB_HOST, + "port": settings.DB_PORT, + "database": settings.DB_NAME, + "username": settings.DB_USER, + "password": settings.DB_PASSWORD, + }, + "qgis": { + "exportDpi": getattr(settings, "QGIS_EXPORT_DPI", 300), + }, + "template_override": { + "enabled": getattr(settings, "QGIS_TEMPLATE_OVERRIDE_ENABLED", False), + "original": { + "host": getattr(settings, "QGIS_TEMPLATE_OVERRIDE_ORIGINAL_HOST", "localhost"), + "port": getattr(settings, "QGIS_TEMPLATE_OVERRIDE_ORIGINAL_PORT", 5432), + "dbname": getattr(settings, "QGIS_TEMPLATE_OVERRIDE_ORIGINAL_DB_NAME", "yjzyk_xian"), + "schema": getattr(settings, "QGIS_TEMPLATE_OVERRIDE_ORIGINAL_SCHEMA", "base"), + }, + "actual": { + "host": getattr(settings, "QGIS_TEMPLATE_OVERRIDE_ACTUAL_HOST", ""), + "port": getattr(settings, "QGIS_TEMPLATE_OVERRIDE_ACTUAL_PORT", ""), + "dbname": getattr(settings, "QGIS_TEMPLATE_OVERRIDE_ACTUAL_DB_NAME", "xian_new"), + "schema": getattr(settings, "QGIS_TEMPLATE_OVERRIDE_ACTUAL_SCHEMA", "qgis"), + }, + }, + "static_layers": _build_static_layers_config(gpkg_dir), + "batch_folder": batch_folder, + } + + +def _build_static_layers_config(gpkg_dir: str) -> dict: + """构建静态底图配置""" + layer_defs = { + "水库": ("qgis.rivers", "rivers.gpkg"), + "市州驻地": ("qgis.sx_capital", "sx_capital.gpkg"), + "河流": ("qgis.river", "river.gpkg"), + "active_fault": ("qgis.active_fault", "active_fault.gpkg"), + "陕西省": ("qgis.sx", "sx.gpkg"), + "乡镇驻地": ("qgis.sx_street", "sx_street.gpkg"), + "区县驻地": ("qgis.sx_xa_county", "sx_xa_county.gpkg"), + "县界": ("qgis.sx_xa_county_boundary", "sx_xa_county_boundary.gpkg"), + "周边区县": ("qgis.sx_zb_county_boundary", "sx_zb_county_boundary.gpkg"), + "周边市州": ("qgis.sx_zb_city", "sx_zb_city.gpkg"), + "周边县区": ("qgis.sx_zb_county", "sx_zb_county.gpkg"), + "traffic_expressway": ("qgis.traffic_expressway", "traffic_expressway.gpkg"), + "traffic_provincial": ("qgis.traffic_provincial", "traffic_provincial.gpkg"), + "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"), + } + + layers = {} + for name, (table, gpkg_file) in layer_defs.items(): + layers[name] = {"file": gpkg_file, "table": table} + + return {"enabled": True, "gpkg_dir": gpkg_dir, "layers": layers} + + +# ============================================================ +# 接口实现 +# ============================================================ + +@router.post("/export/map", response_model=QgisMapExportResponse, summary="QGIS 批量专题图导出") +async def export_map(req: QgisMapExportRequest): + """ + 根据模拟ID批量导出专题图。同一 occurred_time 视为同一场灾害,共享文件夹 + """ + from app.services.qgis.qgis_env import is_qgis_ready + if not is_qgis_ready(): + raise HTTPException(status_code=503, detail="QGIS 环境未初始化,专题图功能不可用") + + semaphore = get_prediction_semaphore() + + async with semaphore: + simulation_id = req.simulationId + loop = asyncio.get_event_loop() + + try: + # 查询推理记录,获取 occurred_time + inference = await loop.run_in_executor( + None, qgis_repository.query_inference_result, simulation_id + ) + + # 将 occurred_time 格式化为时间戳作为文件夹名 + disaster_time = format_disaster_time(inference["occurred_time"]) + + # 构建批次文件夹路径 + file_store = getattr(settings, "FILE_STORE_DIR", "G:/files") + output_tmpl = getattr(settings, "QGIS_OUTPUT_DIR", "xian/qgis/map/:disasterTime") + output_dir = output_tmpl.replace(":disasterTime", disaster_time) + batch_folder = os.path.join(file_store, output_dir).replace("\\", "/") + + # 去重检查:同一 disasterTime 只产图一次 + if os.path.isdir(batch_folder): + existing_files = [ + f for f in os.listdir(batch_folder) if f.endswith(".jpg") + ] + if existing_files and disaster_time not in _in_progress_locks: + logger.info( + f"[去重] disasterTime={disaster_time} 已产图,直接返回 " + f"({len(existing_files)} 张)" + ) + return QgisMapExportResponse( + code=200, + message=f"已存在,共{len(existing_files)}张", + data=disaster_time, + ) + + # 去重锁:同一灾害时间排队等待 + async with _locks_lock: + if disaster_time not in _in_progress_locks: + _in_progress_locks[disaster_time] = asyncio.Lock() + task_lock = _in_progress_locks[disaster_time] + + async with task_lock: + if os.path.isdir(batch_folder): + existing_files = [ + f for f in os.listdir(batch_folder) if f.endswith(".jpg") + ] + if existing_files: + logger.info( + f"[去重] disasterTime={disaster_time} 已被并发请求产图完成" + ) + return QgisMapExportResponse( + code=200, + message=f"已存在,共{len(existing_files)}张", + data=disaster_time, + ) + + logger.info( + f"推理结果查询成功: id={inference['id']}, " + f"type={inference['event_type']}, " + f"occurred_time={inference['occurred_time']}, " + f"disasterTime={disaster_time}" + ) + + # 推导参数 + 构建配置 + os.makedirs(batch_folder, exist_ok=True) + + event_type = inference["event_type"] + template_name_map = {"earthquake": "地震专题图", "rainfall": "降雨专题图"} + template_name = template_name_map.get(event_type, f"{event_type}专题图") + out_filename = f"{template_name}.jpg" + + model = _derive_model_params(inference, batch_folder, out_filename) + config = _build_qgis_config(batch_folder) + + logger.info( + f"模板路径: {model['path']}, " + f"输出: {model['outFile']}, " + f"标题: {model['mapTitle']}, " + f"中心: ({model['centerX']}, {model['centerY']})" + ) + + # 提交到线程池 + _thread_pool.submit( + _generate_single_map, model, config, disaster_time + ) + + return QgisMapExportResponse( + code=200, + message="任务已提交", + data=disaster_time, + ) + + except ValueError as e: + logger.warning(f"参数错误: {e}") + raise HTTPException(status_code=400, detail=str(e)) + except FileNotFoundError as e: + logger.error(f"模板文件不存在: {e}") + raise HTTPException(status_code=404, detail=f"模板文件不存在: {e}") + except Exception as e: + logger.error(f"专题图导出失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"导出失败: {e}") + finally: + pass + + +def _generate_single_map(model: dict, config: dict, disaster_time: str) -> None: + """线程池工作函数:在工作线程中生成单张专题图""" + from app.services.qgis.map_service import MapService + + try: + logger.info(f"[线程池] 开始产图: {model['mapTitle']} → {model['outFile']}") + service = MapService(config) + service.generate(model) + + if os.path.exists(model["outFile"]): + logger.info(f"[线程池] 产图成功: {model['outFile']}") + else: + logger.error(f"[线程池] 产图后文件不存在: {model['outFile']}") + + except Exception as e: + logger.error(f"[线程池] 产图失败: {model['name']} — {e}", exc_info=True) + + +# ============================================================ +# 清理函数 +# ============================================================ + +def shutdown_thread_pool() -> None: + """关闭线程池(在 server.py lifespan 关闭阶段调用)""" + _thread_pool.shutdown(wait=False) + logger.info("QGIS 线程池已关闭") \ No newline at end of file diff --git a/app/core/server.py b/app/core/server.py index 76ccf8c..8b97fa1 100644 --- a/app/core/server.py +++ b/app/core/server.py @@ -9,6 +9,7 @@ from fastapi import FastAPI, Request from app.utils.api_deps import get_rainfall_model, get_earthquake_model, is_model_loaded from app.schemas.api_schemas import HealthResponse from app.config.paths import get_logger +from config import settings logger = get_logger("api") @@ -20,9 +21,29 @@ async def lifespan(app: FastAPI): get_rainfall_model() get_earthquake_model() logger.info("DBN模型预加载完成") - yield - logger.info("应用关闭") + # 初始化 QGIS 环境 + qgis_root = getattr(settings, "QGIS_ROOT", None) + if qgis_root: + try: + from app.services.qgis.qgis_env import init_qgis_env + init_qgis_env(qgis_root) + logger.info("QGIS 环境初始化完成") + except Exception as e: + logger.error(f"QGIS 环境初始化失败(专题图功能不可用): {e}") + + yield + + # 清理 QGIS 资源 + try: + from app.services.qgis.qgis_env import cleanup_qgis_env + from app.services.qgis.map_service import template_cache + template_cache.cleanup() + cleanup_qgis_env() + except Exception: + pass + + logger.info("应用关闭") def create_app() -> FastAPI: """创建 FastAPI 应用实例""" diff --git a/app/repositories/__init__.py b/app/repositories/__init__.py index 82d24f3..c938eb0 100644 --- a/app/repositories/__init__.py +++ b/app/repositories/__init__.py @@ -2,5 +2,6 @@ Repositories package - 数据访问层 """ from app.repositories.rainfall_repository import rainfall_repository, RainfallRepository +from app.repositories.qgis_repository import qgis_repository, QgisRepository -__all__ = ['rainfall_repository', 'RainfallRepository'] +__all__ = ['rainfall_repository', 'RainfallRepository', 'qgis_repository', 'QgisRepository'] diff --git a/app/repositories/qgis_repository.py b/app/repositories/qgis_repository.py new file mode 100644 index 0000000..724ce6d --- /dev/null +++ b/app/repositories/qgis_repository.py @@ -0,0 +1,31 @@ +from app.config.paths import get_logger +from app.utils.db_helper import db_helper + +logger = get_logger("qgis") + + +class QgisRepository: + + @staticmethod + def query_inference_result(inference_id: int) -> dict: + """根据 inferenceId 查询 xian_inference_result""" + sql = """ + SELECT id, name, event_type, occurred_time, condition + FROM xian_inference_result + WHERE id = %s + """ + rows = db_helper.execute_query(sql, (inference_id,)) + if not rows: + raise ValueError(f"推理结果不存在: id={inference_id}") + + row = rows[0] + return { + "id": row[0], + "name": row[1] or "", + "event_type": row[2] or "", + "occurred_time": row[3], + "condition": row[4] if isinstance(row[4], dict) else {}, + } + + +qgis_repository = QgisRepository() diff --git a/app/schemas/api_schemas.py b/app/schemas/api_schemas.py index c4a8042..d175739 100644 --- a/app/schemas/api_schemas.py +++ b/app/schemas/api_schemas.py @@ -44,6 +44,21 @@ class EarthquakePredictRequest(BaseModel): description="操作类型(如 '模拟', '实时监测', '应急评估')") +# ============================================================ +# 专题图产出 +# ============================================================ +class QgisMapExportRequest(BaseModel): + """专题图导出请求""" + inferenceId: int = Field(..., description="推理结果ID(xian_inference_result.id)") + + +class QgisMapExportResponse(BaseModel): + """专题图导出响应""" + code: int = Field(200, description="状态码") + message: str = Field("success", description="提示信息") + data: Optional[str] = Field(None, description="导出图片的访问路径") + + # ============================================================ # 通用响应 # ============================================================ diff --git a/app/script/export_static_layers.py b/app/script/export_static_layers.py new file mode 100644 index 0000000..03727c6 --- /dev/null +++ b/app/script/export_static_layers.py @@ -0,0 +1,80 @@ +""" +一次性脚本:从 PostgreSQL 导出静态底图为 GeoPackage 文件。 +运行一次即可,之后服务直接读本地 GPKG。 + +用法: python -m app.script.export_static_layers +""" +import os +import sys +import time + +# 确保项目根目录在 sys.path 中 +project_root = 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) + +import geopandas as gpd +from sqlalchemy import create_engine +from config import settings + +# GPKG 输出目录(相对于项目根目录) +GPKG_DIR = os.path.join(project_root, getattr(settings, "QGIS_GPKG_DIR", "app/data/gpkg")) + +# 静态图层定义: {显示名: (schema.table, geom_column, srid)} +STATIC_LAYERS = { + "水库": ("qgis.rivers", "Geometry", 0), + "市州驻地": ("qgis.sx_capital", "geometry", 0), + "河流": ("qgis.river", "Geometry", 0), + "active_fault": ("qgis.active_fault", "Geometry", 0), + "陕西省": ("qgis.sx", "Geometry", 0), + "乡镇驻地": ("qgis.sx_street", "dgeom", 0), + "区县驻地": ("qgis.sx_xa_county", "Geometry", 0), + "县界": ("qgis.sx_xa_county_boundary", "Geometry", 0), + "周边区县": ("qgis.sx_zb_county_boundary", "Geometry", 0), + "周边市州": ("qgis.sx_zb_city", "Geometry", 4326), + "周边县区": ("qgis.sx_zb_county", "Geometry", 0), + "traffic_expressway": ("qgis.traffic_expressway", "Geometry", 0), + "traffic_provincial": ("qgis.traffic_provincial", "Geometry", 0), + "traffic_railway": ("qgis.traffic_railway", "Geometry", 0), + "traffic_township": ("qgis.traffic_township", "Geometry", 0), + "traffic_trunk_line": ("qgis.traffic_trunk_line", "Geometry", 0), +} + + +def export_layers(): + os.makedirs(GPKG_DIR, exist_ok=True) + + conn_str = ( + f"postgresql://{settings.DB_USER}:{settings.DB_PASSWORD}" + f"@{settings.DB_HOST}:{settings.DB_PORT}/{settings.DB_NAME}" + ) + engine = create_engine(conn_str) + + for layer_name, (table_ref, geom_col, srid) in STATIC_LAYERS.items(): + t0 = time.time() + schema, table = table_ref.split(".", 1) + print(f"导出 {layer_name} ({table_ref}) ...", end=" ", flush=True) + + try: + sql = f'SELECT * FROM "{schema}"."{table}"' + gdf = gpd.read_postgis(sql, engine, geom_col=geom_col) + + if srid and srid > 0: + gdf = gdf.set_crs(epsg=srid, allow_override=True) + elif gdf.crs is None: + gdf = gdf.set_crs(epsg=4326) + + gpkg_path = os.path.join(GPKG_DIR, f"{table}.gpkg") + gdf.to_file(gpkg_path, driver="GPKG") + + print(f"✓ {len(gdf)} 行, {time.time() - t0:.1f}s") + + except Exception as e: + print(f"✗ 失败: {e}") + + engine.dispose() + print(f"\n导出完成!文件目录: {GPKG_DIR}") + + +if __name__ == "__main__": + export_layers() \ No newline at end of file diff --git a/app/services/qgis/__init__.py b/app/services/qgis/__init__.py new file mode 100644 index 0000000..3bc724e --- /dev/null +++ b/app/services/qgis/__init__.py @@ -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', +] diff --git a/app/services/qgis/layer_filter.py b/app/services/qgis/layer_filter.py new file mode 100644 index 0000000..8e975b2 --- /dev/null +++ b/app/services/qgis/layer_filter.py @@ -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}'") \ No newline at end of file diff --git a/app/services/qgis/map_exporter.py b/app/services/qgis/map_exporter.py new file mode 100644 index 0000000..0282894 --- /dev/null +++ b/app/services/qgis/map_exporter.py @@ -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}") \ No newline at end of file diff --git a/app/services/qgis/map_service.py b/app/services/qgis/map_service.py new file mode 100644 index 0000000..5a85338 --- /dev/null +++ b/app/services/qgis/map_service.py @@ -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"], + ) + # 更新 schema(table="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 图层跳过远程连接") \ No newline at end of file diff --git a/app/services/qgis/qgis_env.py b/app/services/qgis/qgis_env.py new file mode 100644 index 0000000..920365e --- /dev/null +++ b/app/services/qgis/qgis_env.py @@ -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 应用目录。 + + Windows(OSGeo4W 安装): {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 初始化完成") \ No newline at end of file diff --git a/app/services/qgis/template_cache.py b/app/services/qgis/template_cache.py new file mode 100644 index 0000000..3587116 --- /dev/null +++ b/app/services/qgis/template_cache.py @@ -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("已清理所有缓存") diff --git a/app/services/qgis/template_modifier.py b/app/services/qgis/template_modifier.py new file mode 100644 index 0000000..7e10f79 --- /dev/null +++ b/app/services/qgis/template_modifier.py @@ -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"()(.*?)()", 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 \ No newline at end of file diff --git a/app/utils/map_zoom.py b/app/utils/map_zoom.py new file mode 100644 index 0000000..3d30592 --- /dev/null +++ b/app/utils/map_zoom.py @@ -0,0 +1,123 @@ +""" +地图缩放策略模块。支持 5 种缩放模式。 +""" +from qgis.core import ( + QgsPointXY, QgsGeometry, QgsRectangle, + QgsCoordinateTransform, QgsCoordinateReferenceSystem, +) + +from app.config.paths import get_logger + +logger = get_logger("qgis.zoom") + +ZOOM_RULES = { + "11": "pan_to_center", + "12": "by_layer", + "13": "layer_intersect", + "14": "center_distance", + "15": "layer_merged", +} + + +class MapZoom: + def __init__(self, project, layout, map_item): + self.project = project + self.layout = layout + self.map_item = map_item + + def execute(self, rule: str, data: dict) -> None: + """执行缩放操作""" + method_name = ZOOM_RULES.get(rule, "pan_to_center") + method = getattr(self, method_name) + logger.info(f"缩放: {method_name}, 参数: {data}") + method(data) + + def _make_transform(self, source_crs=None) -> QgsCoordinateTransform: + qct = QgsCoordinateTransform() + qct.setDestinationCrs(self.map_item.crs()) + qct.setSourceCrs( + source_crs if source_crs + else QgsCoordinateReferenceSystem("EPSG:4326") + ) + return qct + + def pan_to_center(self, data: dict) -> None: + qct = self._make_transform() + point = QgsPointXY(float(data["X"]), float(data["Y"])) + geom = QgsGeometry.fromWkt(point.asWkt()) + geom.transform(qct, QgsCoordinateTransform.ForwardTransform) + center = geom.asPoint() + qr = QgsRectangle.fromCenterAndSize( + center, self.map_item.extent().width(), self.map_item.extent().height() + ) + self.map_item.zoomToExtent(qr) + + def center_distance(self, data: dict) -> None: + qct = self._make_transform() + point = QgsPointXY(float(data["X"]), float(data["Y"])) + geom = QgsGeometry.fromWkt(point.asWkt()) + geom.transform(qct, QgsCoordinateTransform.ForwardTransform) + box = geom.buffer(float(data["value"]) / 1000, 100).boundingBox() + self.map_item.zoomToExtent(box.buffered(box.width() * 0.1)) + + def by_layer(self, data: dict) -> None: + layers = self.project.mapLayersByName(data["value"]) + if not layers: + logger.warning(f"图层不存在: {data['value']}") + return + layer = layers[0] + if layer.featureCount() == 0: + self.pan_to_center(data) + return + qct = self._make_transform(layer.crs()) + geom = QgsGeometry.fromWkt(layer.extent().asWktPolygon()) + geom.transform(qct, QgsCoordinateTransform.ForwardTransform) + box = geom.boundingBox() + self.map_item.zoomToExtent(box.buffered(box.width() * 0.1)) + + def layer_merged(self, data: dict) -> None: + names = data["value"].split(",") + combined = None + for name in names: + layers = self.project.mapLayersByName(name.strip()) + if not layers: + continue + layer = layers[0] + qct = self._make_transform(layer.crs()) + geom = QgsGeometry.fromWkt(layer.extent().asWktPolygon()) + geom.transform(qct, QgsCoordinateTransform.ForwardTransform) + box = geom.boundingBox() + if combined is None: + combined = box + else: + combined.combineExtentWith(box) + if combined: + self.map_item.zoomToExtent(combined.buffered(combined.width() * 0.1)) + + def layer_intersect(self, data: dict) -> None: + names = data["value"].split(",") + intersection = None + for name in names: + layers = self.project.mapLayersByName(name.strip()) + if not layers: + continue + layer = layers[0] + qct = self._make_transform(layer.crs()) + layer.selectAll() + geom = None + for fid in layer.selectedFeatureIds(): + if geom is None: + geom = layer.getGeometry(fid) + else: + geom = geom.combine(layer.getGeometry(fid)) + if geom: + qgm = QgsGeometry.fromWkt(geom.asWkt()) + qgm.transform(qct, QgsCoordinateTransform.ForwardTransform) + box = qgm.boundingBox() + if intersection is None: + intersection = box + else: + intersection = intersection.intersection(box) + if intersection: + bbox = intersection.boundingBox() + self.map_item.zoomToExtent(bbox.buffered(bbox.width() * 0.1)) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 3c5752c..b8bcc23 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,5 @@ Pillow == 12.2.0 pyyaml == 6.0.3 fastapi == 0.136.3 uvicorn[standard] == 0.48.0 -loguru == 0.7.3 \ No newline at end of file +loguru == 0.7.3 +geopandas == 0.14.4 \ No newline at end of file diff --git a/settings.toml b/settings.toml index 83ba84d..cb6dae0 100644 --- a/settings.toml +++ b/settings.toml @@ -1,57 +1,140 @@ -# 公共配置 +# ============================================================ +# 公共配置(所有环境共享) +# ============================================================ [default] APP_NAME = "西安项目算法服务" LOG_DIR = "logs" -# 雨量站栅格存储位置,:id会被替换成数据id RAIN_STATION_GRID_DIR = "/xian/rainfall/grid/images/:id" -# 雨量站栅格存储redis的key REDIS_RAIN_STATION_GRID_KEY = "xian:rainfall:rain_station_grid" -# 雨量站存储标识符的redis的key REDIS_RAIN_STATION_IDENTIFIER_KEY = "xian:rainfall:rain_station_identifier" -# 预测结果概率阈值(低于此值不返回给前端) PREDICT_PROBABILITY_THRESHOLD = 50 +# 静态底图 GeoPackage 目录(相对于项目根目录) +QGIS_GPKG_DIR = "app/data/gpkg" +# 专题图输出子目录(相对于 FILE_STORE_DIR) +QGIS_OUTPUT_DIR = "xian/qgis/map/:disasterTime" +# 专题图默认参数 +QGIS_DEFAULTS_MAP_LAYOUT = "A3" +QGIS_DEFAULTS_ZOOM_RULE = "11" +QGIS_DEFAULTS_ZOOM_VALUE = "50" +QGIS_DEFAULTS_MAP_UNIT = "西安市应急管理局" +# 专题图DPI +QGIS_EXPORT_DPI = 300 +# 批量产图线程池 +QGIS_WORKER_THREADS = 4 +# 西安市中心经纬度 +XIAN_CENTER = [108.948024, 34.263161] + +# ============================================================ +# 行政区划代码映射 +# ============================================================ +[default.area] +"610102" = "新城区" +"610103" = "碑林区" +"610104" = "莲湖区" +"610111" = "灞桥区" +"610112" = "未央区" +"610113" = "雁塔区" +"610114" = "阎良区" +"610115" = "临潼区" +"610116" = "长安区" +"610117" = "高陵区" +"610118" = "鄠邑区" +"610122" = "蓝田县" +"610124" = "周至县" + +# ============================================================ # 开发环境 +# ============================================================ [development] DEBUG = true -# 数据库 +# ============================================================ +# 数据库配置 +# ============================================================ DB_HOST = "47.92.216.173" DB_PORT = 7654 DB_USER = "postgres" DB_PASSWORD = "zhangsan" DB_NAME = "xian_new" -# FastAPI配置 +# ============================================================ +# FastAPI 配置 +# ============================================================ API_HOST = "127.0.0.1" API_PORT = 8082 +# ============================================================ # 日志配置 +# ============================================================ LOG_LEVEL = "DEBUG" -# redis +# ============================================================ +# Redis 配置 +# ============================================================ REDIS_HOST = "47.92.216.173" REDIS_PORT = 7655 REDIS_PASSWORD = "zhangsan" REDIS_DB = 0 -# 文件存储 +# ============================================================ +# 文件路径配置 +# ============================================================ FILE_STORE_DIR = "G:/files" +# ============================================================ +# QGIS 配置 +# ============================================================ +QGIS_ROOT = "D:/QGIS" +# 模板数据库覆盖:将模板中硬编码的连接替换为实际环境连接 +QGIS_TEMPLATE_OVERRIDE_ENABLED = true +QGIS_TEMPLATE_OVERRIDE_ORIGINAL_HOST = "localhost" +QGIS_TEMPLATE_OVERRIDE_ORIGINAL_PORT = 5432 +QGIS_TEMPLATE_OVERRIDE_ORIGINAL_DB_NAME = "yjzyk_xian" +QGIS_TEMPLATE_OVERRIDE_ORIGINAL_SCHEMA = "base" +QGIS_TEMPLATE_OVERRIDE_ACTUAL_HOST = "47.92.216.173" +QGIS_TEMPLATE_OVERRIDE_ACTUAL_PORT = 7654 +QGIS_TEMPLATE_OVERRIDE_ACTUAL_DB_NAME = "xian_new" +QGIS_TEMPLATE_OVERRIDE_ACTUAL_SCHEMA = "qgis" - +# ============================================================ # 生产环境 +# ============================================================ [production] DEBUG = false +# ============================================================ # 数据库配置 +# ============================================================ DB_HOST = "10.22.245.138" DB_PORT = 54321 DB_USER = "zaihailian" DB_PASSWORD = "XAYJ@gis2603" DB_NAME = "xianDC" -# FastAPI配置 +# ============================================================ +# FastAPI 配置 +# ============================================================ API_HOST = "127.0.0.1" API_PORT = 8081 +# ============================================================ # 日志配置 +# ============================================================ LOG_LEVEL = "WARNING" -# redis +# ============================================================ +# Redis 配置 +# ============================================================ REDIS_HOST = "localhost" REDIS_PORT = 6379 REDIS_PASSWORD = "XAYJ@gis2603" REDIS_DB = 0 -# 文件存储 -FILE_STORE_DIR = "D:/files" \ No newline at end of file +# ============================================================ +# 文件路径配置 +# ============================================================ +FILE_STORE_DIR = "/data" +# ============================================================ +# QGIS 配置 +# ============================================================ +QGIS_ROOT = "/home/QGIS" +# 模板数据库覆盖:将模板中硬编码的连接替换为实际环境连接 +QGIS_TEMPLATE_OVERRIDE_ENABLED = true +QGIS_TEMPLATE_OVERRIDE_ORIGINAL_HOST = "localhost" +QGIS_TEMPLATE_OVERRIDE_ORIGINAL_PORT = 5432 +QGIS_TEMPLATE_OVERRIDE_ORIGINAL_DB_NAME = "yjzyk_xian" +QGIS_TEMPLATE_OVERRIDE_ORIGINAL_SCHEMA = "base" +QGIS_TEMPLATE_OVERRIDE_ACTUAL_HOST = "10.22.245.138" +QGIS_TEMPLATE_OVERRIDE_ACTUAL_PORT = 54321 +QGIS_TEMPLATE_OVERRIDE_ACTUAL_DB_NAME = "xian_new" +QGIS_TEMPLATE_OVERRIDE_ACTUAL_SCHEMA = "qgis"