IS
Все эксперименты
живое

day-summ – день, размеченный камерой, пульсом и локальной LLM

Камера ноутбука размечает минуты на фокус, телефон и усталость, ремешок Whoop отдает пульс по BLE, локальная модель пишет вечерний вердикт. Все офлайн: кадры не сохраняются, в базе – только числа по минутам.

лабораторная запись
Зачем делал

Whoop показывает пульс, сон и recovery, но молчит о том, чем я в эти минуты занимался. Трекеры экранного времени знают приложения, но не знают, смотрел ли я в экран вообще. Хотелось одну картинку дня, где физиология и поведение лежат на одной оси времени – и чтобы для этого не пришлось ничего отдавать в облако.

Что узнал

Самое полезное различие получилось не из камеры, а из ее отсутствия. Если лица в кадре нет, но пульс по BLE продолжает капать – я рядом, это короткий перерыв. Если пропал и пульс – я вышел из комнаты. Одна проверка `hr_avg is not None` заменила целый класс эвристик. Еще: MediaPipe отдает blendshapes (моргание, зевок, наклон взгляда) готовыми числами, так что PERCLOS и частота морганий считаются в пару строк, без обучения своих моделей.

Где сломалось

Ради батареи камера снимает срезами: ~10 секунд каждые 5 минут. И двадцатиминутная отлучка на таймлайне превращалась в четыре одноминутных пятнышка – потому что размечены были только те минуты, где реально были кадры. Пришлось добавить sample-and-hold: каждый срез «держит» свою категорию до следующего среза, но не дольше лимита, иначе закрытая крышка ноутбука растянулась бы на весь вечер.

Стек
PythonMediaPipeYOLO11Whoop BLEOllamaSQLite

Один день на одной оси

У меня уже были данные о теле: Whoop честно пишет пульс, сон и recovery. Но там нет ответа на вопрос «а что я в это время делал». Трекеры экранного времени отвечают ровно наоборот: знают приложение, но не знают, смотрел ли я в экран или залипал в телефон рядом с ноутбуком.

day-summ сводит оба потока в один вечерний отчет: таймлайн дня по минутам, окна усталости, пульс в фокусе против пульса в телефоне – и текстовый вердикт, который пишет модель, крутящаяся на этом же ноутбуке.

живое демо· sandbox
Демо загружается…

Как минута получает свою метку

Кадры не анализируются по одному: все сводится к поминутной записи (доля присутствия в кадре, доля взгляда в экран, доля кадров с телефоном, моргания, PERCLOS, средний пульс), а дальше минута получает ровно одну категорию – чистой функцией, без магии:

def minute_category(rec, cfg, workout_spans):
    if not rec.data_ok:
        return "nodata"
    if overlaps_workout(rec, workout_spans):
        return "workout"
    if rec.presence_ratio < cfg.break_presence_max:
        # лица нет: пульс идет → я рядом, пульса нет → вышел
        return "break" if rec.hr_avg is not None else "left"
    if rec.phone_detected_ratio > cfg.phone_minute_ratio:
        return "phone"
    if (rec.presence_ratio >= cfg.focus_presence
            and rec.gaze_on_screen_ratio >= cfg.focus_gaze
            and rec.phone_detected_ratio < cfg.focus_phone_max):
        return "focus"
    if rec.gaze_on_screen_ratio < cfg.away_gaze_max:
        return "away"
    return "idle"

Лицо и взгляд считает MediaPipe Face Landmarker: он отдает blendshapes (eyeBlinkLeft, jawOpen, eyeLookDown, browDown) и матрицу поворота головы, из которой вынимаются pitch и yaw. Отсюда же берутся моргания в минуту и PERCLOS – доля времени с закрытыми глазами, классическая метрика сонливости.

Телефон в кадре ловит YOLO11-nano по классу COCO cell phone – и только на каждом десятом кадре, потому что детектор тяжелее лица.

Перерыв или ушел – решает пульс

Самая приятная находка проекта. Камера видит одно и то же – пустой кадр – в двух совершенно разных ситуациях: я откинулся на спинку кресла и думаю, или я ушел из комнаты. Различает их BLE: ремешок Whoop в режиме Broadcast Heart Rate вещает по стандартному Heart Rate Service, и пока я в радиусе пары метров, пульс продолжает приходить. Пропал пульс – значит, ушел я, а не отвернулся.

Батарея: срезы вместо непрерывной съемки

Держать камеру включенной весь день – это греющийся ноутбук и минус несколько часов автономности. Поэтому по умолчанию камера просыпается на 10 секунд каждые 5 минут (sample_duration_sec / sample_interval_sec в конфиге), а BLE-пульс висит на связи постоянно – он почти ничего не ест. На macOS еще проверяется крышка через ioreg (AppleClamshellState): закрыта – камеру даже не дергаем.

Текст пишет локальная модель, а цифры – код

Вердикт наверху отчета генерирует qwen3:8b через локальный ollama. Важный принцип: модель не считает – она пересказывает. Все факты (минуты фокуса, окна усталости, пульс в разрезе категорий, recovery и сон из Whoop API) вычисляет код и отдает готовым JSON, а промпт прямо просит «не пересчитывай».

Если ollama не запущен, отчет не ломается: собирается шаблонная сводка на f-строках. У qwen3 еще и режим рассуждения – блок <think>…</think> из ответа вырезается регуляркой, в отчет идет только чистовик.

uv run daysumm calibrate   # один раз: пороги под свое рабочее место
uv run daysumm start       # утром: сбор + живой дашборд на localhost:9683
uv run daysumm report      # вечером: собрать и открыть HTML-отчет

Приватность как архитектура, а не как обещание

Такую штуку невозможно сделать «почти приватной». Либо кадры не сохраняются вообще, либо однажды окажется, что где-то лежит папка с твоим лицом за полгода. Поэтому в проекте нет ни одной ветки, где кадр доходит до диска.