"""Public statistics from a single, safely filtered aerial.videos catalog."""
from __future__ import annotations
from datetime import date
import html
import math
from typing import Callable, Mapping, Sequence
import pandas as pd
import plotly.graph_objects as go
import streamlit as st
from live_inventory import VideoCatalog, VideoRecord, filter_records, summarize_records
from city_labels import city_display_name, is_city_label
from dashboard_charts import DATA_BLUE, style_figure
_UNKNOWN = "未知"
_FILTER_KEYS = ("pg_cities", "pg_date_range", "pg_include_unknown_dates",
"pg_codecs", "pg_periods")
_EXPORT_COLUMNS = (
"city", "collection_month", "period", "codec", "resolution", "fps",
"duration_range", "videos", "duration_hours", "size_tb",
"duration_known_videos", "size_known_videos",
)
def selected_records(catalog: VideoCatalog, state: Mapping) -> tuple[VideoRecord, ...]:
"""Use the same inclusive filters for KPIs, charts, maps and export."""
# Other pages and unavailable renders have no filter widgets. Their shared
# facts must still use the last selection, while current widgets override it.
state = {**state.get("_pg_filter_snapshot", {}),
**{key: state[key] for key in _FILTER_KEYS if key in state}}
dates = state.get("pg_date_range", ())
if isinstance(dates, date):
dates = (dates, dates)
start = dates[0] if dates else None
end = dates[-1] if dates else None
return filter_records(
catalog.records,
cities=state.get("pg_cities", ()),
start_date=start, end_date=end,
include_unknown_dates=state.get("pg_include_unknown_dates", True),
codecs=state.get("pg_codecs", ()),
periods=state.get("pg_periods", ()),
)
def _resolution(record: VideoRecord) -> str:
return f"{record.width} × {record.height}" if record.width and record.height else _UNKNOWN
def _fps(record: VideoRecord) -> str:
return f"{float(record.fps):g}" if record.fps is not None else _UNKNOWN
def _duration_range(record: VideoRecord) -> str:
value = record.duration_seconds
if value is None:
return _UNKNOWN
if value < 60:
return "< 1 min"
if value < 300:
return "1–5 min"
if value < 900:
return "5–15 min"
if value < 1800:
return "15–30 min"
if value < 3600:
return "30–60 min"
return "≥ 60 min"
def aggregate_rows(records: Sequence[VideoRecord]) -> pd.DataFrame:
"""Only coarse, aggregate metadata can be downloaded from the dashboard."""
groups: dict[tuple, list] = {}
for record in records:
key = (record.city, record.collected_on.strftime("%Y-%m") if record.collected_on else _UNKNOWN,
record.period, record.codec or _UNKNOWN, _resolution(record),
_fps(record), _duration_range(record))
totals = groups.setdefault(key, [0, 0.0, 0.0, 0, 0])
totals[0] += 1
if record.duration_seconds is not None:
totals[1] += float(record.duration_seconds) / 3600
totals[3] += 1
if record.size_bytes is not None:
totals[2] += float(record.size_bytes) / 1_000_000_000_000
totals[4] += 1
return pd.DataFrame(
[(city_display_name(key[0]), *key[1:], totals[0], totals[1] if totals[3] else None,
totals[2] if totals[4] else None, totals[3], totals[4])
for key, totals in sorted(groups.items())],
columns=_EXPORT_COLUMNS,
)
def duration_hours_by(records: Sequence[VideoRecord], key: Callable[[VideoRecord], str]) -> dict[str, float | None]:
"""Sum actual known durations per category; unknown-only is not measured zero."""
totals = {}
for record in records:
bucket = totals.setdefault(key(record), [0.0, False])
seconds = record.duration_seconds
if seconds is not None and not isinstance(seconds, bool) and math.isfinite(float(seconds)) and seconds >= 0:
bucket[0] += float(seconds) / 3600
bucket[1] = True
return {value: hours if known else None for value, (hours, known) in totals.items()}
def public_aggregate_rows(records: Sequence[VideoRecord]) -> pd.DataFrame:
"""Keep internal count fields for validation, expose hours and quality states."""
rows = aggregate_rows(records)
for kind in ('duration', 'size'):
known = rows[f'{kind}_known_videos']
rows[f'{kind}_status'] = ['unknown' if n == 0 else 'partial' if n < total else 'known'
for n, total in zip(known, rows.videos)]
return rows.drop(columns=['videos', 'duration_known_videos', 'size_known_videos'])
def render_dashboard(load_catalog: Callable, clear_catalog: Callable,
translate: Callable[[str], str], *, render_annotations: Callable | None = None) -> None:
"""Refresh the whole dashboard together, never a separate population."""
def tr(zh: str, en: str | None = None) -> str:
translated = translate(zh)
if st.session_state.get("lang") == "en" and translated == zh and en:
return en
return translated
def category(value: str) -> str:
if is_city_label(value):
return city_display_name(value)
labels = {
_UNKNOWN: "Unknown", "缺少坐标": "Missing coordinates",
"边界外或坐标异常": "Unassigned city",
"已归属城市": "Assigned city", "早高峰 (07-09)": "Morning peak (07-09)",
"日常规": "Daytime", "晚高峰 (17-19)": "Evening peak (17-19)",
"夜间 (19-07)": "Night (19-07)",
"未知采集时间": "Unknown collection date", "known": "Known", "partial": "Partly unknown",
"unknown": "Unknown", "< 1 min": "< 0.017 h", "1–5 min": "0.017–0.083 h",
"5–15 min": "0.083–0.25 h", "15–30 min": "0.25–0.5 h", "30–60 min": "0.5–1 h", "≥ 60 min": "≥ 1 h",
}
if value in {"< 1 min", "1–5 min", "5–15 min", "15–30 min", "30–60 min", "≥ 60 min"}:
return labels[value]
if value in {'known', 'partial', 'unknown'}:
return tr({'known':'已知','partial':'部分未知','unknown':'未知'}[value], labels[value])
return tr(value, labels.get(value))
def heading(anchor: str, zh: str, en: str, description: str = "", english: str = "") -> None:
st.markdown(
f"
"
f"{html.escape(tr(zh, en))}
", unsafe_allow_html=True,
)
if description:
st.caption(tr(description, english))
def bar(counts: Mapping[str, float | None], title: str, english: str,
*, order: Sequence[str] | None = None) -> None:
keys = [key for key in order if key in counts] if order else sorted(counts, key=lambda key: (counts[key] is None, -(counts[key] or 0), key))
if order:
keys.extend(key for key in counts if key not in keys)
figure = go.Figure(go.Bar(x=[category(key) for key in keys],
y=[counts[key] for key in keys],
marker_color=DATA_BLUE,
hovertemplate="%{x}: %{y:.3f} h"))
style_figure(figure, title=tr(title, english), height=320)
figure.update_yaxes(title='h', rangemode="tozero", tickformat=",.2f")
st.plotly_chart(figure, width="stretch")
def unavailable_annotations() -> None:
if render_annotations is not None:
render_annotations()
return
st.info(tr(
"当前视频表没有道路、天气、光照、动态目标、审核关键帧或经审定地点标签,相关标注分布与覆盖率暂不可统计。",
"The video table does not contain road, weather, lighting, dynamic-object, reviewed-keyframe or approved-location labels. Their distributions and annotation coverage are unavailable.",
))
@st.fragment(run_every="60s")
def render_current_catalog() -> None:
heading("nav-scenario", "实时视频统计", "Live video statistics")
if st.button(tr("刷新数据", "Refresh data"), key="pg_refresh"):
clear_catalog()
catalog, status = load_catalog()
if status == "not_configured":
st.info(tr("尚未配置 PostgreSQL 数据源。", "PostgreSQL data source is not configured."))
return
if status != "ok" or catalog is None:
st.warning(tr("PostgreSQL 数据暂不可用,请稍后刷新。", "PostgreSQL data is unavailable. Please refresh later."))
return
# Streamlit removes widget state when an unavailable page does not
# render its controls. Keep a nonwidget snapshot so recovery preserves
# the user's filters instead of silently showing the entire inventory.
saved_filters = st.session_state.get("_pg_filter_snapshot", {})
for key, value in saved_filters.items():
if key not in st.session_state:
st.session_state[key] = list(value) if isinstance(value, list) else value
updated = str(catalog.latest_updated_at)[:19] if catalog.latest_updated_at else "—"
st.caption(f"{tr('数据源', 'Source')}: aerial_db · aerial.videos | "
f"{tr('库内最近更新', 'Latest database update')}: {updated} | "
f"{tr('每 60 秒自动刷新', 'Refreshes every 60 seconds')}")
if not catalog.records:
st.info(tr("当前数据库没有有效视频记录。", "The database has no active video records."))
with st.expander(tr("筛选条件", "Filters"), expanded=True):
# Retain removed selections as available options. Clearing them would
# turn a deliberately narrow filter into the entire new population.
city_options = sorted({record.city for record in catalog.records} |
set(st.session_state.get("pg_cities", ())))
codec_options = sorted({record.codec or _UNKNOWN for record in catalog.records} |
set(st.session_state.get("pg_codecs", ())))
period_options = sorted({record.period for record in catalog.records} |
set(st.session_state.get("pg_periods", ())))
left, right = st.columns(2)
with left:
st.multiselect(tr("城市", "City"), city_options, key="pg_cities", format_func=category)
dates = [record.collected_on for record in catalog.records if record.collected_on]
if dates:
bounds = (min(dates), max(dates))
previous_bounds = st.session_state.get("_pg_date_bounds")
selected_dates = st.session_state.get("pg_date_range")
if selected_dates is None or tuple(selected_dates) == previous_bounds:
st.session_state["pg_date_range"] = bounds
st.session_state["_pg_date_bounds"] = bounds
if dates or "pg_date_range" in st.session_state:
# The default 'today' reads an existing range from state;
# this avoids competing widget-default and state values.
st.date_input(tr("采集日期范围", "Collection date range"),
key="pg_date_range")
else:
st.caption(tr("采集日期均未知。", "All collection dates are unknown."))
if "pg_include_unknown_dates" not in st.session_state:
st.session_state["pg_include_unknown_dates"] = True
st.checkbox(tr("包含未知采集日期", "Include unknown collection dates"),
key="pg_include_unknown_dates")
with right:
st.multiselect(tr("编码格式", "Codec"), codec_options, key="pg_codecs", format_func=category)
st.multiselect(tr("采集时段", "Collection period"), period_options,
key="pg_periods", format_func=category)
st.caption(tr("时段按北京时间分组;每个图表与导出均应用以上筛选。",
"Periods use China Standard Time. These filters apply to every chart and export."))
st.session_state["_pg_filter_snapshot"] = {
key: list(st.session_state[key]) if isinstance(st.session_state[key], list)
else st.session_state[key]
for key in _FILTER_KEYS if key in st.session_state
}
records = selected_records(catalog, st.session_state)
snapshot = summarize_records(records, catalog.latest_updated_at)
city_count = len({record.city for record in records if record.gps_status == "已归属城市"})
duration_known = sum(record.duration_seconds is not None for record in records)
size_known = sum(record.size_bytes is not None for record in records)
duration_display = f"{snapshot.duration_hours:,.1f}" if not records or duration_known else "—"
size_display = f"{snapshot.size_tb:,.2f}" if not records or size_known else "—"
gps_records = tuple(record for record in records if record.gps_status != "缺少坐标")
gps_hours = sum(record.duration_seconds / 3600 for record in gps_records if record.duration_seconds is not None)
gps_display = f"{gps_hours:,.2f}" if not gps_records or any(record.duration_seconds is not None for record in gps_records) else '—'
metrics = (
(duration_display, tr("已知时长合计(h)", "Known duration total (h)")),
(size_display, tr("已知容量合计(TB)", "Known size total (TB)")),
(str(city_count), tr("覆盖城市", "Cities")),
(gps_display, tr("含坐标视频时长(h)", "Geolocated video duration (h)")),
)
for index, (column, (value, label)) in enumerate(zip(st.columns(4), metrics)):
with column:
style = ("kpi-blue", "kpi-indigo", "kpi-teal", "kpi-purple")[index % 4]
st.markdown(f"{value}
"
f"
{html.escape(label)}
", unsafe_allow_html=True)
if not records:
st.info(tr("当前筛选没有匹配的视频,请调整条件。", "No videos match these filters. Adjust the selections."))
heading("nav-odd", "元数据与标注状态", "Metadata and annotation status")
unavailable_annotations()
return
heading("nav-city", "城市与采集分布", "Cities and collection distribution",
"城市按采集坐标与行政区边界归属;地图点为城市行政区代表点,不表示视频采集位置。",
"Cities are assigned from collection GPS and administrative boundaries. Map markers represent city administrative areas, not video collection positions.")
city_counts = duration_hours_by(records, lambda record: record.city)
centers = {center.city: center for center in catalog.city_centers}
mapped_cities = sorted(set(city_counts) & set(centers))
left, right = st.columns(2)
with left:
if mapped_cities:
figure = go.Figure(go.Scattermap(
lon=[centers[city].longitude for city in mapped_cities],
lat=[centers[city].latitude for city in mapped_cities],
text=[f"{category(city)} · " + (f"{city_counts[city]:.3f} h" if city_counts[city] is not None else tr("时长未知", "Duration unknown")) for city in mapped_cities],
customdata=[city_counts[city] for city in mapped_cities],
mode="markers", marker=dict(size=14, color=DATA_BLUE),
hovertemplate="%{text}",
))
style_figure(figure, height=340)
figure.update_layout(map=dict(style="carto-positron", center=dict(lat=36, lon=113), zoom=3),
margin=dict(l=0, r=0, t=0, b=0), height=340)
st.plotly_chart(figure, width="stretch")
else:
st.info(tr("当前筛选没有可归属城市的记录。", "No selected videos can be assigned to a city."))
with right:
bar(city_counts, "城市视频时长(h)", "Video duration by city (h)")
left, right = st.columns(2)
with left:
months = duration_hours_by(records, lambda record: record.collected_on.strftime("%Y-%m") if record.collected_on else _UNKNOWN)
bar(months, "采集月份时长(h)", "Duration by collection month (h)", order=sorted(months))
with right:
bar(duration_hours_by(records, lambda record: record.period), "采集时段分布(h)", "Collection period (h)",
order=("早高峰 (07-09)", "日常规", "晚高峰 (17-19)", "夜间 (19-07)", _UNKNOWN))
heading("nav-params", "视频参数分布", "Video parameters")
for left_spec, right_spec in (
((lambda record: record.codec or _UNKNOWN, "编码格式分布", "Codec distribution"),
(_resolution, "分辨率分布", "Resolution distribution")),
((_fps, "帧率分布", "Frame rate distribution"),
(_duration_range, "时长区间分布", "Duration distribution")),
):
for column, (key, zh, en) in zip(st.columns(2), (left_spec, right_spec)):
with column:
order = ("< 1 min", "1–5 min", "5–15 min", "15–30 min", "30–60 min", "≥ 60 min", _UNKNOWN) if key is _duration_range else None
bar(duration_hours_by(records, key), zh + "(h)", en + " (h)", order=order)
heading("nav-odd", "元数据与标注状态", "Metadata and annotation status",
"所有分布按已知视频时长(h)汇总;缺失字段独立显示,未知时长不推算,空值与测得零分开。",
"Distributions sum known video duration in hours. Missing metadata remains separate; unknown durations are not estimated and blank is distinct from measured zero.")
quality_specs = (
("采集日期", "Collection date", lambda record: record.collected_on is not None),
("城市归属", "City attribution", lambda record: record.gps_status == "已归属城市"),
("编码", "Codec", lambda record: record.codec not in (None, "", _UNKNOWN)),
("分辨率", "Resolution", lambda record: bool(record.width and record.height)),
("帧率", "Frame rate", lambda record: record.fps is not None),
("时长", "Duration", lambda record: record.duration_seconds is not None),
("容量", "Size", lambda record: record.size_bytes is not None),
("入库日期", "Ingestion date", lambda record: record.added_on is not None),
)
labels, present, absent = [], [], []
for zh, en, predicate in quality_specs:
labels.append(tr(zh, en))
hours = duration_hours_by(records, lambda record: 'known' if predicate(record) else 'unknown')
present.append(hours.get('known', 0.0))
absent.append(hours.get('unknown', 0.0))
figure = go.Figure((
go.Bar(name=tr("已知 / 有效", "Known / valid"), x=labels, y=present, marker_color=DATA_BLUE),
go.Bar(name=tr("未知 / 无效", "Unknown / invalid"), x=labels,
y=absent, marker_color="#dce5ec"),
))
style_figure(figure, height=320)
figure.update_layout(barmode="stack", margin=dict(t=50))
figure.update_yaxes(title='h', rangemode="tozero", tickformat=",.2f")
figure.update_traces(hovertemplate='%{x}: %{y:.3f} h%{fullData.name}')
st.plotly_chart(figure, width="stretch")
left, right = st.columns(2)
with left:
bar(duration_hours_by(records, lambda record: record.gps_status), "坐标归属时长(h)", "GPS attribution duration (h)")
with right:
additions = duration_hours_by(records, lambda record: record.added_on.strftime("%Y-%m") if record.added_on else _UNKNOWN)
bar(additions, "入库月份时长(h)", "Duration by ingestion month (h)", order=sorted(additions))
unavailable_annotations()
st.subheader(tr("当前筛选汇总", "Filtered aggregate data"))
st.caption(tr("时长单位 h;空值表示该组时长未知,部分未知状态表示仅合计已知部分;测得零保留为零。",
"Duration is in h. Blank means unknown; partial means the known portion only. A measured zero remains zero."))
exported = public_aggregate_rows(records)
exported['duration_range'] = exported.duration_range.map(category)
exported['duration_status'] = exported.duration_status.map(category)
exported['size_status'] = exported.size_status.map(category)
st.dataframe(exported, hide_index=True, width="stretch", column_config={
"city": tr("城市", "City"), "collection_month": tr("采集月份", "Collection month"),
"period": tr("时段", "Period"), "codec": tr("编码", "Codec"),
"resolution": tr("分辨率", "Resolution"), "fps": tr("帧率", "FPS"),
"duration_range": tr("时长区间", "Duration range"),
"duration_hours": tr("时长(h)", "Duration (h)"), "size_tb": tr("容量(TB)", "Size (TB)"),
"duration_status": tr("时长完整度", "Duration completeness"),
"size_status": tr("容量完整度", "Size completeness"),
})
st.download_button(tr("下载汇总 CSV", "Download aggregate CSV"),
data=exported.to_csv(index=False).encode("utf-8-sig"),
file_name="aerial-videos-statistics.csv", mime="text/csv", key="pg_download")
render_current_catalog()