A self-hosted PHP gallery for webcam images. No database, no build step — copy the files to your server and you have a full image archive with dawn-to-dusk filtering, weather, aurora borealis and people detection, 21-language support, and good SEO. Navigates with swipe on phones and arrow keys on desktops. Loads fast.
- Features
- Getting started
- Aurora borealis gallery
- People gallery
- Bulk image operations
- Performance
- Screenshots
- Calculates sunrise, sunset, dawn, and dusk from latitude and longitude — only shows images taken between dawn and dusk
- Handles midnight sun and polar night
- Touch gestures and arrow-key navigation
- Time overlay on thumbnails (day, month, and year views)
- Weather from Open-Meteo (historical) and Yr (current day)
- Aurora borealis gallery with live Yr forecast and animated NOAA/SWPC map
- People/vehicle/animal detection gallery powered by YOLOv8
- Client-side image caching, lazy loading, and prefetching
- Responsive images via
srcset(three sizes) with WebP thumbnails — optimised for mobile and desktop - Inline CSS,
fetchpriority="high"on the LCP image, and instant.page + Speculation Rules for fast navigation - Multilingual UI in 21 languages (en, de, it, fr, nb, nl, es, ja, zh, ko, sv, da, pl, fi, pt, th, tr, id, hi, ms, uk) with auto-detection from browser language, persistent cookie, and SEO hreflang tags
2026/
├── 01/
│ └── 15/
│ ├── 20260115083000.jpg
│ ├── 20260115083010.jpg
│ └── mini/
│ └── 20260115083000.jpg ← 160×120 thumbnail
└── 02/
└── ...
- Copy all PHP files to your web server.
- Edit
WebcamConfig.php— set your coordinates, timezone, analytics IDs, and filename prefix. - Edit
cron/copy-latest-image.shandcron/rename_and_make_mini_images.shfor your camera's filename format, then add them to cron. Seeutil/crontab.txt. - Verify calculated sunrise/sunset at yr.no.
- Update the midnight sun and polar night date ranges in
WebcamConfig.phpif applicable.
lang.php contains translations for 21 languages, but several keys — webcam_intro, seo_description, seo_description_short — contain place names and URLs specific to Lillevik Lofoten. To deploy for a different location, override them before including webcam.php:
define('CAM_WEBCAM_INTRO', '<a href=".">Webcam</a> at My Place, My Town, My Country.');
define('CAM_SEO_DESCRIPTION', 'Live webcam at My Place, My Town. Updated every 10 minutes.');
define('CAM_SEO_DESCRIPTION_SHORT', 'Live webcam at My Place. Updated every 10 minutes.');
require_once __DIR__ . '/webcam.php';These override the translated version for all languages. All other translation keys (navigation labels, weather terms, month names, etc.) are generic and need no changes.
webcam.php— main entry point and HTML renderingWebcamConfig.php— all configuration constantsSunCalculator.php— sunrise/sunset/dawn/dusk, midnight sun, polar nightImageFileManager.php— finding and organizing image filesNavigationHelper.php— navigation URL generationlang.php— multilingual support (21 languages, auto-detection, hreflang SEO)aurora.php— northern lights gallerypeople.php— people/vehicle/animal detection galleryaurora_scan.py— scores images for aurora likelihoodpeople_scan.py— detects people using YOLOv8sun_calculator.py— Python mirror ofSunCalculator.php, used by the scan scripts
See CODE_STRUCTURE.md for full class documentation.
aurora_scan.py scores each image for aurora likelihood using OpenCV. Results above --threshold are saved to aurora-YYYY.json and shown by aurora.php, which also displays a live Yr forecast and an animated NOAA/SWPC polar map for the current month.
Each image is decoded at quarter resolution, then the bottom 35% (ground, sea, lights) is discarded. The remaining sky region is converted to HSV and scored on four signals:
1. Green/teal pixel coverage — the fraction of sky pixels that fall within aurora hue ranges. Two ranges are scored separately and the higher wins:
- Classic green: H 38–85 (yellow-green), S ≥ 55, V ≥ 25
- Teal/cyan: H 38–100 (extends into cyan), S ≥ 55, V ≥ 25
The hue cap at H = 100 is deliberate. Pre-dawn and post-dusk twilight sky at high latitudes sits at H 100–130 (blue). Capping at 100 rejects it without affecting real aurora.
2. Local contrast — mean absolute deviation between each pixel's brightness (V channel) and a Gaussian-blurred version of itself. Aurora has visible structure and texture; smooth overcast sky scores near zero.
3. Connected component size — aurora forms patches and bands, so large connected regions of matched pixels are a positive signal. The contribution is capped at 20% of image area: a single blob covering more than that is more likely background sky than an aurora band.
4. Patch bonus — if the largest connected component of classic green pixels (H 38–85) contains ≥ 200 pixels, a fixed bonus of 0.10 is added to the raw score. A compact, well-defined green patch in a dark sky is unambiguously aurora even when total sky coverage is low. The bonus applies only to the classic green range, not teal, to avoid triggering on polar night atmospheric blue-grey glow.
5. Global green cast — the mean of G − (R + B) / 2 across the sky. A sky that is uniformly green-shifted (green overcast) is penalised.
These are combined linearly:
score = green_ratio × 1.8
+ local_contrast × 1.2
+ min(largest_cc_ratio, 0.20) × 1.5
+ patch_bonus (0.10 if largest classic-green CC ≥ 200 px, else 0)
− global_green_cast × 0.8
Brightness factor — the whole score is multiplied by a factor that approaches zero as the mean sky brightness rises above ~0.18 (normalised). This suppresses high-latitude spring/autumn twilight images where the sky is still lit but the sun is technically below the horizon.
Time filter — by default only images taken outside dawn–dusk (9° solar depression) are scanned. Midnight sun months are skipped entirely. During polar night, a fake dawn/dusk window (06:00–17:00) is applied so midday images with residual twilight glow are excluded, same as a normal day.
python3 -m venv venv && source venv/bin/activate
pip install opencv-python numpy astral# Update one month (fast — good for routine use)
python3 aurora_scan.py /path/to/images/2026/03 --threshold 0.08 --json-output data/aurora-2026.json
# Full year (slow — use for initial build)
python3 aurora_scan.py /path/to/images/2026 --threshold 0.08 --json-output data/aurora-2026.json
# Daily incremental update
python3 aurora_scan.py /path/to/images/2026/03/15 --threshold 0.08 --append --json-output data/aurora-2026.jsonWhen the output file already exists, only the scanned months are replaced — the rest is preserved.
| Option | Description |
|---|---|
--threshold N |
Minimum score to include (0.08 is a good starting point) |
--day |
Include daytime images (default: night only) |
--limit N |
Cap stdout report at N results (JSON output is unaffected) |
--workers N |
Parallel workers (default: all cores; use 1–2 for network drives) |
--append |
Upsert individual timestamps instead of replacing the whole month |
people_scan.py detects people, vehicles, and animals using YOLOv8 (nano model, ~6 MB, downloaded automatically). Score = highest detection confidence in the frame. Results are saved to people-YYYY.json and shown by people.php.
Three false-positive suppression layers:
- Civil-twilight time filter — skips images outside usable daylight
- Static exclusion zones — ignores detections in known-static areas (sky, water, fixed structures)
- Background subtraction — rejects detections that match the per-pixel median background
python3 -m venv venv && source venv/bin/activate
pip install ultralytics astral opencv-python numpy# Step 1 — build background model once (~800 MB peak RAM)
python3 people_scan.py /path/to/images/2026 --build-background data/background-2026.png
# Step 2 — scan (re-run whenever new images arrive)
python3 people_scan.py /path/to/images/2026 --civil-day --threshold 0.3 \
--background data/background-2026.png \
--exclude-zone 0.0,0.0,1.0,0.60 \
--exclude-zone 0.0,0.60,0.45,0.68 \
--exclude-zone 0.52,0.70,0.61,0.81 \
--exclude-zone 0.40,0.88,0.46,0.99 \
--json-output data/people-2026.jsonIf --background points to a non-existent file the model is built automatically before scanning. Exclusion zones are fractions of image width/height — calibrate for your scene using --annotate.
python3 people_scan.py /dev/null \
--annotate /path/to/image.jpg annotated.jpg \
--background data/background-2026.png \
--exclude-zone 0.0,0.0,1.0,0.60 \
--exclude-zone 0.0,0.60,0.45,0.68--annotate saves an image showing every YOLO box (green = kept, red = rejected by zone, orange = rejected by background), zone overlays, and the foreground mask.
| Option | Description |
|---|---|
--threshold N |
Minimum confidence to include (0–1; 0.3 is a good starting point) |
--civil-day |
Civil twilight filter (6° depression) — fewer low-light false positives than --day |
--day |
Nautical twilight filter (12° depression) |
--background FILE |
Background model PNG; auto-built if missing |
--build-background FILE |
Build background model and exit |
--exclude-zone x1,y1,x2,y2 |
Ignore detections centred in this zone (repeatable) |
--annotate IMAGE OUTPUT |
Annotate one image for diagnosis and exit |
--fg-overlap N |
Min foreground fraction of detection box (default 0.15) |
--bg-diff N |
Pixel diff threshold for foreground detection (default 25) |
--bg-samples N |
Frames sampled when building background (default 300) |
--limit N |
Cap stdout report at N results (JSON output is unaffected) |
--workers N |
Parallel workers (default: all cores; use 1–2 for network drives) |
--append |
Upsert individual timestamps instead of replacing the whole month |
util/webcam-image-organize-fix.sh reorganizes images into YYYY/MM/DD directories.
util/nctpput-all-images.sh mass-uploads files using ncftp.
util/delete_old_images.py thins out old images (dry-run by default):
python3 util/delete_old_images.py --delete --one-per-hour # keep one per hour
python3 util/delete_old_images.py --compress-quality 80 # recompress (requires Pillow)The main webcam page is optimised for fast load times:
| Technique | Details |
|---|---|
| Responsive latest image | srcset with three sizes (650w / 900w / 1800w) generated by cron — browser picks the right one |
| WebP thumbnails | <picture> element with WebP source and JPEG fallback; thumbnails at 300×300 |
| Inline CSS | CSS is inlined at render time — eliminates the render-blocking <link rel="stylesheet"> request |
| LCP hint | fetchpriority="high" on the main image so the browser fetches it first |
| Prefetch | <link rel="prefetch"> for previous and next pages (and their images), fired immediately on page load |
| Prerender | Speculation Rules API prerenders prev/next in Chrome/Edge; silently ignored in other browsers |
| Hover prefetch | instant.page prefetches links on hover (~300 ms head start) in all browsers |
Google PageSpeed scores for lilleviklofoten.no/webcam:
| Performance | Accessibility | Best Practices | SEO | |
|---|---|---|---|---|
| Mobile | 90 | 93 | 100 | 100 |
| Desktop | 98 | 93 | 100 | 100 |
Single image:
Full day — all images from dawn to dusk, with time overlay:
Full month — one image per day at ~12:00:
If you find this useful, buy me a coffee ☕️


