Count people in photos and videos, right from Telegram

Send a photo or a short video to your bot and get back the people count with age group and gender demographics β€” powered by MiVOLO (YOLOv8 + transformer). Privacy-first: media is deleted right after the analysis.

v0.1.2 released 100% test coverage 3 languages (ptΒ·enΒ·es) MIT license
bot β€” python3
$ python3 bot.py
How it works

From a Telegram message to a head count

No camera integration, no dashboards to deploy β€” the chat you already use is the interface.

1

Send media

A photo, or a slow 20–30s pan video, sent to your private bot on Telegram.

2

Detect

YOLOv8 finds every person and face in the scene β€” bodies and faces are matched.

3

Estimate

MiVOLO estimates age and gender per person; videos track unique people across frames.

4

Reply & log

You get the summary in chat; only aggregated numbers go to a CSV history. Media is deleted.

Live demo

Real photo, real output

The exact summary below was produced by the bot for this public sample photo β€” 13 people found, all 13 with demographics.

Sample photo: group of people
Public sample photo (people.jpg) β€” also used by the integration tests
photo sent to the bot Sunday meeting 14:32 βœ“βœ“
Photo received, analyzing...14:32
Summary - Sunday meeting
Total people: 13
With estimated demographics: 13

Children: 0 M, 1 F
Adults: 4 M, 8 F
14:32

Videos count unique people

A person that appears in 300 frames is still one person: the tracker follows each individual across the pan, drops noisy tracks, and aggregates age by median and gender by majority vote.

Public sample video (people.mp4) β€” 13.7s, 343 frames @ 25 fps

Tracking result

Duration13.7 s
Frames processed343
Unique people tracked37
Demographics estimated37 / 37
Inbound ports opened0

Tracks shorter than 3 frames are discarded as tracker noise, so ID switches don't inflate the count.

The numbers

Small bot, serious engineering

Every number below is reproducible from the repository.

0%
test coverage β€” 103 tests, gate at 80% in CI
0
unique people tracked in a single 14s video
0
people counted in one photo, all with demographics
0
reply languages β€” pt, en, es β€” picked per sender
0
inbound ports β€” long polling only, nothing exposed
0
per photo on Apple Silicon (15–40s on a CPU VPS)
Get started

Run it locally (macOS / Linux)

Five commands from clone to a running bot. The first run downloads ~250 MB of model weights and verifies their SHA256 before loading.

1 Β· Install

Python 3.12 is recommended (brew install python@3.12). The PIP_CONSTRAINT works around a MiVOLO build quirk with modern setuptools β€” it's already in the repo.

2 Β· Configure

Copy .env.example to .env, fill in your bot token and your Telegram user id. The file stays out of git and readable only by you.

3 Β· Run

Send /start to your bot, then a photo. That's it.

zsh β€” local setup
$ git clone https://github.com/HectorIFC/count-people && cd count-people
$ python3.12 -m venv .venv && source .venv/bin/activate
$ PIP_CONSTRAINT=$PWD/constraints.txt pip install -r requirements.txt
# ... resolves torch (MPS on Apple Silicon), MiVOLO, OpenCV ...
$ cp .env.example .env && chmod 600 .env && $EDITOR .env
$ set -a && source .env && set +a && python3 bot.py
INFO Loading models (first run downloads ~250 MB)...
INFO Models ready (device: mps).
INFO Bot running (long polling). Ctrl+C to stop.
Always on

Run it on a VPS

The bot only makes outbound TLS connections to api.telegram.org β€” no webhook, no reverse proxy, no port to expose. Lock the box down and let systemd keep it alive.

Harden first

Key-only SSH, default-deny firewall, brute-force protection and automatic security patches. The full hardened systemd unit (sandboxed with ProtectSystem=strict, secrets via root-only EnvironmentFile) is in the README.

  1. Create a dedicated no-shell user (peoplebot)
  2. Install with CPU-only torch (~10Γ— smaller than the CUDA package)
  3. Enable the people-counter systemd service
ssh β€” ubuntu vps
# firewall: deny everything inbound except SSH
$ sudo ufw default deny incoming && sudo ufw allow OpenSSH && sudo ufw enable
# CPU-only torch, then the project deps
$ pip install torch --index-url https://download.pytorch.org/whl/cpu
$ PIP_CONSTRAINT=$PWD/constraints.txt pip install -r requirements.txt
# secrets root-only, service sandboxed
$ sudo install -m 600 -o root -g root /dev/null /etc/people-counter/env
$ sudo systemctl enable --now people-counter
● people-counter.service β€” active (running)
Telegram

Create and lock your bot

Two chats with official Telegram bots and one line in your .env. The allowlist is mandatory β€” the bot refuses to start without it and silently ignores anyone else.

/newbot14:20 βœ“βœ“
Alright, a new bot. How are we going to call it?14:20
People Counter14:21 βœ“βœ“
Done! Use this token to access the HTTP API:
8613…:AAF… πŸ”‘14:21
@userinfobot: Your id: 11111111114:22

Three steps

  1. Talk to @BotFather β†’ /newbot β†’ save the token (it's a secret β€” only in .env, never in git or chats).
  2. Talk to @userinfobot β†’ get your numeric user id.
  3. Put both in .env:
    TELEGRAM_BOT_TOKEN=…
    ALLOWED_USER_IDS=111111111

Optional: send /setuserpic to @BotFather and upload the project logo.

Privacy & security

Designed to not leak anything

OWASP-guided hardening, verified by tests.

Media never persists

Photos and videos are processed in a temporary directory and deleted right after. Only aggregated numbers reach the CSV (created with mode 600).

Fail-closed allowlist

No ALLOWED_USER_IDS, no startup. Strangers get no reply at all β€” the bot never confirms it exists; attempts are only logged.

SHA256-pinned models

Weights are pickle-loaded, so every startup verifies their SHA256 against pinned hashes. Tampered file β†’ deleted β†’ abort.

Zero attack surface

Long polling means outbound-only TLS to api.telegram.org. No webhook, no open port, nothing for strangers to scan.

Injection-proof history

Captions are truncated, stripped of control characters and formula prefixes before touching the CSV.

Secrets stay secret

Token only via environment; .env is gitignored and chmod 600; the httpx logger is silenced so the token never reaches logs.