File size: 22,255 Bytes
439fd56
 
 
 
 
 
a2bad67
439fd56
 
 
 
 
 
 
d1430bc
956b532
439fd56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d1430bc
439fd56
 
 
 
 
 
a2bad67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439fd56
 
 
 
 
 
 
 
 
 
d1430bc
 
439fd56
 
d1430bc
439fd56
 
 
a2bad67
 
 
439fd56
a2bad67
 
 
 
439fd56
 
 
 
 
 
 
 
 
 
a2bad67
439fd56
a2bad67
439fd56
 
 
a2bad67
956b532
a2bad67
956b532
a2bad67
439fd56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2bad67
 
 
439fd56
a2bad67
439fd56
 
a2bad67
439fd56
a2bad67
439fd56
 
 
 
 
 
 
 
 
 
 
 
 
a2bad67
439fd56
 
 
 
 
 
 
 
a2bad67
439fd56
956b532
439fd56
 
956b532
439fd56
 
 
 
 
 
a2bad67
439fd56
 
a2bad67
 
439fd56
a2bad67
439fd56
 
 
 
 
 
 
 
 
 
 
 
a2bad67
439fd56
 
a2bad67
 
439fd56
 
 
 
 
 
 
 
 
 
a2bad67
439fd56
 
a2bad67
 
 
439fd56
956b532
439fd56
a2bad67
439fd56
956b532
 
a2bad67
 
439fd56
 
 
a2bad67
439fd56
a2bad67
 
439fd56
 
 
a2bad67
 
 
 
 
 
439fd56
 
 
 
a2bad67
 
 
 
439fd56
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
"""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"<div id='{anchor}' class='dash-section'><h2 class='dash-section-title'>"
            f"{html.escape(tr(zh, en))}</h2></div>", 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<extra></extra>"))
        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"<div class='kpi-card {style}'><div class='kpi-num'>{value}</div>"
                            f"<div class='kpi-lbl'>{html.escape(label)}</div></div>", 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}<extra></extra>",
                ))
                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<extra>%{fullData.name}</extra>')
        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()