feat(v1): per-frame fall decision diagnostics overlay
PersonAnalysis now carries the policy Evidence; view_model builds a Qt-free per-person diagnostic line (acc/horiz/ang/rapid/cand/state, or the reject reason) and VideoView overlays it in a corner. Read-only, no decision change; locates where a real fall stops. 67 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -32,7 +32,7 @@
|
|||||||
| T-205 | 固化客户演示脚本与 V1 发布包 | T-204 | 可按文档完成演示脚本,所有证据可复查,适用边界显示给操作员。 | BLOCKED |
|
| T-205 | 固化客户演示脚本与 V1 发布包 | T-204 | 可按文档完成演示脚本,所有证据可复查,适用边界显示给操作员。 | BLOCKED |
|
||||||
| T-206 | 结构化摄像头配置、连接测试与设置页预览 | T-201 | 配置支持 host/端口/通道/账号/密码 结构化来源并拼出 RTSP(凭证百分号编码);公开示例保持无凭证、`config_version` 不含凭证;设置页可掩码输入、点击测试连接,连通后抓拍一帧显示在预览区。 | DONE |
|
| T-206 | 结构化摄像头配置、连接测试与设置页预览 | T-201 | 配置支持 host/端口/通道/账号/密码 结构化来源并拼出 RTSP(凭证百分号编码);公开示例保持无凭证、`config_version` 不含凭证;设置页可掩码输入、点击测试连接,连通后抓拍一帧显示在预览区。 | DONE |
|
||||||
| T-207 | 抓流低延迟参数进配置与设置页 | T-206 | 配置支持 transport(tcp/udp)/连接超时/低延迟,程序拼 `OPENCV_FFMPEG_CAPTURE_OPTIONS` 并在开流前注入;设置页用下拉/数字/勾选而非原始字符串;启动可不依赖 run 脚本设环境变量。 | DONE |
|
| T-207 | 抓流低延迟参数进配置与设置页 | T-206 | 配置支持 transport(tcp/udp)/连接超时/低延迟,程序拼 `OPENCV_FFMPEG_CAPTURE_OPTIONS` 并在开流前注入;设置页用下拉/数字/勾选而非原始字符串;启动可不依赖 run 脚本设环境变量。 | DONE |
|
||||||
| T-208 | 每帧摔倒判定诊断叠加 | T-201 | 管线暴露每人策略证据;实时画面角落叠加每人 `accepted/horizontal/角度/rapid/candidate/state`(拒绝时显示原因),便于定位真实摔倒未确认卡在哪一步;诊断字符串构建 Qt-free 可测。 | DOING |
|
| T-208 | 每帧摔倒判定诊断叠加 | T-201 | 管线暴露每人策略证据;实时画面角落叠加每人 `accepted/horizontal/角度/rapid/candidate/state`(拒绝时显示原因),便于定位真实摔倒未确认卡在哪一步;诊断字符串构建 Qt-free 可测。 | DONE |
|
||||||
|
|
||||||
## Phase 3 · Go V2 迁移门槛与实现
|
## Phase 3 · Go V2 迁移门槛与实现
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-21
|
- 日期:2026-07-21
|
||||||
- 阶段:V1 事件管线、PyQt 双 Tab 界面、本地报警工件、结构化摄像头配置/连接测试与抓流低延迟参数已建立;T-202、T-206、T-207 已验收,T-203 进行中(真实海康流已连通,GUI 现场预览待 Windows 冒烟)。
|
- 阶段:V1 事件管线、PyQt 双 Tab 界面、本地报警工件、结构化摄像头配置/连接测试、抓流低延迟参数与每帧判定诊断叠加已建立;T-202、T-206、T-207、T-208 已验收,T-203 进行中(真实海康流已连通,GUI 现场预览待 Windows 冒烟)。
|
||||||
- 已验证环境:Windows PowerShell;Python 3.8.10;Ultralytics 8.3.205;PyQt5 可导入。注意:当前 WSL/Linux 环境未安装 PyQt5,且其 ultralytics/torch 版本与该基线不兼容(模型前向不可用),故 GUI 可视化与真实模型端到端渲染只在 Windows 验证;本环境用 Qt-free 的 `view_model.py` 单元测试与假适配器管线冒烟覆盖视图逻辑。
|
- 已验证环境:Windows PowerShell;Python 3.8.10;Ultralytics 8.3.205;PyQt5 可导入。注意:当前 WSL/Linux 环境未安装 PyQt5,且其 ultralytics/torch 版本与该基线不兼容(模型前向不可用),故 GUI 可视化与真实模型端到端渲染只在 Windows 验证;本环境用 Qt-free 的 `view_model.py` 单元测试与假适配器管线冒烟覆盖视图逻辑。
|
||||||
- 旧生产基线:`demo/main.py`、`demo/fall_detection_gui.py`、`demo/detect_fall.py`、`demo/best.pt`。
|
- 旧生产基线:`demo/main.py`、`demo/fall_detection_gui.py`、`demo/detect_fall.py`、`demo/best.pt`。
|
||||||
- V1 代码:已建立安全配置、显式 Replay/Stream 视频源、Pose、轻量跟踪、质量/几何证据、倒地领域规则、按 ID 四态事件机及 `v1/pipeline.py` 事件管线;运行事件带非敏感 `config_version`,缺帧/低质量/断流会中断证据确认。新增 `v1/view_model.py`(Qt-free 监控视图状态与设置草稿三份隔离)、`v1/gui.py` 薄 PyQt5 双 Tab 外壳与 `v1/app.py` 装配(`FrameWorker` 只发出已判定的 `FrameAnalysis`,窗口只渲染)。新增 `v1/alerts.py`:按 `event_id` 去重,对首次 CONFIRMED 保存带标注截图、追加 JSONL 事件行,并经可注入 `AlertSink`(Windows 侧 `QtAlertSink` 提供声音与一次性弹窗)触发一次声音/弹窗。新增 `v1/camera.py`(RTSP URL 构建 + 有界连接测试抓帧)与 `config.py` 的结构化来源(host/端口/通道/账号/密码,凭证百分号编码)、`write_local_camera_source`;设置页新增摄像头连接分组、测试连接与预览,以及传输协议/连接超时/低延迟三项抓流调优(由 `config` 存值、`app` 开流前写入 `OPENCV_FFMPEG_CAPTURE_OPTIONS`)。真实海康流已用 `VideoSource(STREAM)` 连通(1920×1080)。
|
- V1 代码:已建立安全配置、显式 Replay/Stream 视频源、Pose、轻量跟踪、质量/几何证据、倒地领域规则、按 ID 四态事件机及 `v1/pipeline.py` 事件管线;运行事件带非敏感 `config_version`,缺帧/低质量/断流会中断证据确认。新增 `v1/view_model.py`(Qt-free 监控视图状态与设置草稿三份隔离)、`v1/gui.py` 薄 PyQt5 双 Tab 外壳与 `v1/app.py` 装配(`FrameWorker` 只发出已判定的 `FrameAnalysis`,窗口只渲染)。新增 `v1/alerts.py`:按 `event_id` 去重,对首次 CONFIRMED 保存带标注截图、追加 JSONL 事件行,并经可注入 `AlertSink`(Windows 侧 `QtAlertSink` 提供声音与一次性弹窗)触发一次声音/弹窗。新增 `v1/camera.py`(RTSP URL 构建 + 有界连接测试抓帧)与 `config.py` 的结构化来源(host/端口/通道/账号/密码,凭证百分号编码)、`write_local_camera_source`;设置页新增摄像头连接分组、测试连接与预览,以及传输协议/连接超时/低延迟三项抓流调优(由 `config` 存值、`app` 开流前写入 `OPENCV_FFMPEG_CAPTURE_OPTIONS`)。真实海康流已用 `VideoSource(STREAM)` 连通(1920×1080)。
|
||||||
- V2 代码:`v2/` 目录存在但尚无实现。
|
- V2 代码:`v2/` 目录存在但尚无实现。
|
||||||
- 非代码设计工件:docs/ui/silver-pose-ui-ux-spec.md、docs/ui/2026-07-20-html-prototype-plan.md、docs/ui/silver-pose-v1-prototype.html 与 docs/ui/silver-pose-v2-prototype.html 已建立。v2 HTML 是符合正式浅色 Windows 规范的当前视觉参考:浅灰蓝底、白色卡片,红色只表示确认摔倒、其弹窗和事件证据;文件名中的 v2 只表示原型设计修订,不能理解为 Go V2 实现已开始。v1 HTML 保留为历史深色对照。两者均使用顶部双 Tab、设置草稿与状态交互,且画面、事件和时间都是模拟数据,不连接真实摄像头、模型或网络,也不改变 Phase 1 任务顺序。
|
- 非代码设计工件:docs/ui/silver-pose-ui-ux-spec.md、docs/ui/2026-07-20-html-prototype-plan.md、docs/ui/silver-pose-v1-prototype.html 与 docs/ui/silver-pose-v2-prototype.html 已建立。v2 HTML 是符合正式浅色 Windows 规范的当前视觉参考:浅灰蓝底、白色卡片,红色只表示确认摔倒、其弹窗和事件证据;文件名中的 v2 只表示原型设计修订,不能理解为 Go V2 实现已开始。v1 HTML 保留为历史深色对照。两者均使用顶部双 Tab、设置草稿与状态交互,且画面、事件和时间都是模拟数据,不连接真实摄像头、模型或网络,也不改变 Phase 1 任务顺序。
|
||||||
- 测试:`python -m compileall -q v1 demo` 已通过(含 `gui.py`、`app.py`、`alerts.py`、`camera.py` 语法);`python -m pytest v1/tests -v` 当前有 64 项配置、视频源、Pose、跟踪、证据、领域规则、状态机、管线、视图模型、报警工件、摄像头/URL 构建和抓流参数拼串测试并已通过(含来源模式显式声明、模型置信度经适配器生效、事件号跨轮唯一、结构化凭证不入 config_version 且公开示例无凭证的守卫)。`demo/1.mp4` 的首两帧回放时间戳已验证为 0.000000 与 0.033333 秒;T-106 的真实模型/录像 smoke 在首帧得到 2 名已分析人员、第二帧得到 0 名人员且未创建事件;T-201 的视图冒烟以真实录像解码 + 真实管线 + 确定性假 Pose 适配器驱动 `build_monitor_view`,得到稳定 ID、box、14 段骨架、17/17 关键点与 NORMAL/success,断流帧 0 人且不显示摔倒标签;T-202 的报警冒烟用 `demo/1.mp4` 首帧(848×480)落盘一张可被 `cv2.imread` 读回的标注截图(480×848×3,约 330 KB)与一行 JSONL(相对截图路径、含 config_version/source_id、无 rtsp),重复派发返回 0。这些只验证管线、视图与报警工件可运行,不表示摔倒识别准确率。`init.ps1` 会检查运行时依赖、编译旧基线并运行 V1 测试,但不会安装软件包。
|
- 测试:`python -m compileall -q v1 demo` 已通过(含 `gui.py`、`app.py`、`alerts.py`、`camera.py` 语法);`python -m pytest v1/tests -v` 当前有 67 项配置、视频源、Pose、跟踪、证据、领域规则、状态机、管线、视图模型、报警工件、摄像头/URL 构建、抓流参数拼串和每帧诊断测试并已通过(含来源模式显式声明、模型置信度经适配器生效、事件号跨轮唯一、结构化凭证不入 config_version 且公开示例无凭证的守卫)。`demo/1.mp4` 的首两帧回放时间戳已验证为 0.000000 与 0.033333 秒;T-106 的真实模型/录像 smoke 在首帧得到 2 名已分析人员、第二帧得到 0 名人员且未创建事件;T-201 的视图冒烟以真实录像解码 + 真实管线 + 确定性假 Pose 适配器驱动 `build_monitor_view`,得到稳定 ID、box、14 段骨架、17/17 关键点与 NORMAL/success,断流帧 0 人且不显示摔倒标签;T-202 的报警冒烟用 `demo/1.mp4` 首帧(848×480)落盘一张可被 `cv2.imread` 读回的标注截图(480×848×3,约 330 KB)与一行 JSONL(相对截图路径、含 config_version/source_id、无 rtsp),重复派发返回 0。这些只验证管线、视图与报警工件可运行,不表示摔倒识别准确率。`init.ps1` 会检查运行时依赖、编译旧基线并运行 V1 测试,但不会安装软件包。
|
||||||
- 模型:`demo/best.pt` 可加载为 YOLO Pose,类别 `person`,`kpt_shape=[17, 3]`;与 `D:\PythonP\fall_detection\best.pt` 哈希一致。
|
- 模型:`demo/best.pt` 可加载为 YOLO Pose,类别 `person`,`kpt_shape=[17, 3]`;与 `D:\PythonP\fall_detection\best.pt` 哈希一致。
|
||||||
- 当前标准启动:`./init.ps1`。
|
- 当前标准启动:`./init.ps1`。
|
||||||
- 当前标准验证:`python -m compileall -q demo`。
|
- 当前标准验证:`python -m compileall -q demo`。
|
||||||
|
|||||||
@@ -289,3 +289,12 @@
|
|||||||
- 阻塞:界面角落叠加渲染需 Windows + PyQt5 冒烟;诊断字符串构建与管线暴露策略证据在本环境可测。
|
- 阻塞:界面角落叠加渲染需 Windows + PyQt5 冒烟;诊断字符串构建与管线暴露策略证据在本环境可测。
|
||||||
- 决策:`FallPipeline` 的 `PersonAnalysis` 增加策略 `Evidence`(`is_fall_candidate`/`is_recovery_candidate`),`view_model` 增 Qt-free 的 `person_diagnostic_line` 与 `MonitorViewState.diagnostics`,GUI 在画面角落叠加;不改判定逻辑,只暴露 `accepted/horizontal/角度/rapid/candidate/state` 与拒绝原因。
|
- 决策:`FallPipeline` 的 `PersonAnalysis` 增加策略 `Evidence`(`is_fall_candidate`/`is_recovery_candidate`),`view_model` 增 Qt-free 的 `person_diagnostic_line` 与 `MonitorViewState.diagnostics`,GUI 在画面角落叠加;不改判定逻辑,只暴露 `accepted/horizontal/角度/rapid/candidate/state` 与拒绝原因。
|
||||||
- 下一步:先写诊断字符串失败测试,再实现管线暴露与叠加渲染。
|
- 下一步:先写诊断字符串失败测试,再实现管线暴露与叠加渲染。
|
||||||
|
|
||||||
|
## 【2026-07-21】T-208 每帧摔倒判定诊断叠加(完成)
|
||||||
|
|
||||||
|
- 状态:DONE
|
||||||
|
- 变更:`pipeline.py` 的 `PersonAnalysis` 增加策略 `Evidence` 字段(`is_fall_candidate`/`is_recovery_candidate`)并在 `process` 中填充。`view_model.py` 新增 Qt-free `person_diagnostic_line`(accepted 时输出 `acc=1 horiz/ang/rapid/cand`,拒绝时输出 `acc=0 <reason>`)与 `MonitorViewState.diagnostics`。`gui.py` 的 `VideoView` 在画面左上角半透明叠加每人诊断行。不改任何判定逻辑,只暴露决策中间量。
|
||||||
|
- 验证:新增 view_model 三项测试(accepted 决策字段格式、拒绝原因格式、build_monitor_view 每人一行诊断);`python3 -m pytest v1/tests -q` 为 67 passed;`python3 -m compileall -q v1 demo` 退出码 0。真实链路冒烟:假适配器立位人经真实 `FallPipeline`→`build_monitor_view` 得到 `P-0001 NORMAL acc=1 horiz=0 ang=90deg rapid=0 cand=0`(立位躯干近垂直、角度 90°、非水平、无快速下移、非候选,符合预期)。
|
||||||
|
- 阻塞:画面角落叠加渲染需 Windows + PyQt5 冒烟;诊断构建与管线暴露本环境已验证。
|
||||||
|
- 决策:诊断只读、不改判定;用于定位 1.2 米俯视实测中摔倒未确认卡点——重点看 `rapid`(俯视下髋部图像位移小,可能长期为 0,即根本进不了 SUSPECT)、`acc`(摔倒瞬间膝踝质量是否被拒)、以及是否进了 SUSPECT 但未撑满 1.8s。
|
||||||
|
- 下一步:据现场诊断读数决定调阈值还是换俯视判据(可能需新 task);继续 T-203 现场验收。
|
||||||
|
|||||||
@@ -123,8 +123,25 @@ class VideoView(QtWidgets.QWidget):
|
|||||||
painter.drawImage(target, self._image)
|
painter.drawImage(target, self._image)
|
||||||
if self._view is not None:
|
if self._view is not None:
|
||||||
self._draw_overlays(painter, target, self._image)
|
self._draw_overlays(painter, target, self._image)
|
||||||
|
self._draw_diagnostics(painter)
|
||||||
painter.end()
|
painter.end()
|
||||||
|
|
||||||
|
def _draw_diagnostics(self, painter: QtGui.QPainter) -> None:
|
||||||
|
lines = list(self._view.diagnostics)
|
||||||
|
if not lines:
|
||||||
|
return
|
||||||
|
painter.setFont(QtGui.QFont("Consolas", 9))
|
||||||
|
metrics = painter.fontMetrics()
|
||||||
|
line_h = metrics.height()
|
||||||
|
box_w = max(metrics.width(line) for line in lines) + 12
|
||||||
|
box_h = line_h * len(lines) + 8
|
||||||
|
painter.fillRect(QtCore.QRect(6, 6, box_w, box_h), QtGui.QColor(0, 0, 0, 160))
|
||||||
|
painter.setPen(QtGui.QColor("#9BE9A8"))
|
||||||
|
y = 6 + line_h
|
||||||
|
for line in lines:
|
||||||
|
painter.drawText(12, y, line)
|
||||||
|
y += line_h
|
||||||
|
|
||||||
def _fitted_rect(self, image: QtGui.QImage) -> QtCore.QRect:
|
def _fitted_rect(self, image: QtGui.QImage) -> QtCore.QRect:
|
||||||
widget = self.rect()
|
widget = self.rect()
|
||||||
scale = min(widget.width() / image.width(), widget.height() / image.height())
|
scale = min(widget.width() / image.width(), widget.height() / image.height())
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class PersonAnalysis:
|
|||||||
tracked_pose: TrackedPersonPose
|
tracked_pose: TrackedPersonPose
|
||||||
pose_evidence: PoseEvidence
|
pose_evidence: PoseEvidence
|
||||||
state: FallState
|
state: FallState
|
||||||
|
evidence: Evidence
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -129,6 +130,7 @@ class FallPipeline:
|
|||||||
tracked_pose=tracked,
|
tracked_pose=tracked,
|
||||||
pose_evidence=pose_evidence,
|
pose_evidence=pose_evidence,
|
||||||
state=self._state_machine.state_of(tracked.track_id),
|
state=self._state_machine.state_of(tracked.track_id),
|
||||||
|
evidence=evidence,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._active_track_ids = current_ids
|
self._active_track_ids = current_ids
|
||||||
|
|||||||
@@ -84,6 +84,41 @@ def test_confirmed_event_becomes_a_badge():
|
|||||||
assert view.events[0].latency_seconds == 1.8
|
assert view.events[0].latency_seconds == 1.8
|
||||||
|
|
||||||
|
|
||||||
|
def test_person_diagnostic_line_shows_decision_fields():
|
||||||
|
from v1.view_model import person_diagnostic_line
|
||||||
|
|
||||||
|
pose_evidence = SimpleNamespace(
|
||||||
|
accepted=True, horizontal_pose=False, rapid_vertical_change=False,
|
||||||
|
horizontal_angle_degrees=72.0, reason="accepted",
|
||||||
|
)
|
||||||
|
evidence = SimpleNamespace(is_fall_candidate=False, is_recovery_candidate=False)
|
||||||
|
person = SimpleNamespace(
|
||||||
|
tracked_pose=SimpleNamespace(track_id="P-0001"),
|
||||||
|
state=FallState.NORMAL, pose_evidence=pose_evidence, evidence=evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert person_diagnostic_line(person) == "P-0001 NORMAL acc=1 horiz=0 ang=72deg rapid=0 cand=0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_person_diagnostic_line_shows_reject_reason():
|
||||||
|
from v1.view_model import person_diagnostic_line
|
||||||
|
|
||||||
|
pose_evidence = SimpleNamespace(accepted=False, reason="required_joint_low_confidence")
|
||||||
|
person = SimpleNamespace(
|
||||||
|
tracked_pose=SimpleNamespace(track_id="P-0002"),
|
||||||
|
state=FallState.NORMAL, pose_evidence=pose_evidence, evidence=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert person_diagnostic_line(person) == "P-0002 NORMAL acc=0 required_joint_low_confidence"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_monitor_view_includes_a_diagnostic_per_person():
|
||||||
|
view = build_monitor_view(_analysis(people=[_person("P-0001", FallState.NORMAL)]))
|
||||||
|
|
||||||
|
assert len(view.diagnostics) == 1
|
||||||
|
assert view.diagnostics[0].startswith("P-0001 NORMAL")
|
||||||
|
|
||||||
|
|
||||||
def _initial():
|
def _initial():
|
||||||
return {
|
return {
|
||||||
"keypoint_confidence_threshold": 0.4,
|
"keypoint_confidence_threshold": 0.4,
|
||||||
|
|||||||
@@ -99,6 +99,34 @@ class MonitorViewState:
|
|||||||
people: Tuple[PersonOverlay, ...]
|
people: Tuple[PersonOverlay, ...]
|
||||||
events: Tuple[EventBadge, ...]
|
events: Tuple[EventBadge, ...]
|
||||||
highest_state: FallState
|
highest_state: FallState
|
||||||
|
diagnostics: Tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
def person_diagnostic_line(person) -> str:
|
||||||
|
"""Compact per-person view of the fall decision, to locate where it stops.
|
||||||
|
|
||||||
|
Shows the quality gate (``acc``), horizontal geometry (``horiz``/``ang``),
|
||||||
|
the rapid-drop signal (``rapid``), the policy candidate (``cand``) and the
|
||||||
|
state. When the pose is rejected it shows the reason instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
track_id = person.tracked_pose.track_id
|
||||||
|
state = person.state.value
|
||||||
|
pose_evidence = person.pose_evidence
|
||||||
|
if pose_evidence is None or not pose_evidence.accepted:
|
||||||
|
reason = getattr(pose_evidence, "reason", "no_evidence")
|
||||||
|
return "{0} {1} acc=0 {2}".format(track_id, state, reason)
|
||||||
|
angle = pose_evidence.horizontal_angle_degrees
|
||||||
|
angle_text = "{0:.0f}deg".format(angle) if angle is not None else "-"
|
||||||
|
candidate = int(getattr(person.evidence, "is_fall_candidate", False))
|
||||||
|
return "{0} {1} acc=1 horiz={2} ang={3} rapid={4} cand={5}".format(
|
||||||
|
track_id,
|
||||||
|
state,
|
||||||
|
int(pose_evidence.horizontal_pose),
|
||||||
|
angle_text,
|
||||||
|
int(pose_evidence.rapid_vertical_change),
|
||||||
|
candidate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_monitor_view(analysis, keypoint_min_confidence: float = 0.4) -> MonitorViewState:
|
def build_monitor_view(analysis, keypoint_min_confidence: float = 0.4) -> MonitorViewState:
|
||||||
@@ -128,6 +156,7 @@ def build_monitor_view(analysis, keypoint_min_confidence: float = 0.4) -> Monito
|
|||||||
people=overlays,
|
people=overlays,
|
||||||
events=events,
|
events=events,
|
||||||
highest_state=_highest_state(overlays),
|
highest_state=_highest_state(overlays),
|
||||||
|
diagnostics=tuple(person_diagnostic_line(person) for person in analysis.people),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user