diff --git a/README.md b/README.md
index 34c96c8..f19c71d 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,8 @@ streamlined and repeatable process to monitor signs and signals along any roadwa
* **ssoss_gui.py** - optional graphical front end built with Gooey that exposes the same features through a GUI.
## Requirements
- Python 3.9
-- Required libraries: pandas, numpy, opencv-python, geopy, gpxpy, imageio, tqdm, lxml, pillow, piexif, timezonefinder
+- Required libraries: pandas, numpy, opencv-python, geopy, gpxpy, imageio, tqdm, lxml, pillow, piexif, timezonefinder, fastapi, uvicorn, jinja2
+HTMX is loaded from its CDN when using the photo review web app.
## Installation
Windows OS users can use the [Releases](https://github.com/redmond2742/ssoss/releases) to download an .exe of SSOSS for simple graphical usage. For Mac and Linux users, the command line option is described below.
diff --git a/dev-requirements.in b/dev-requirements.in
index d895d1d..499761f 100644
--- a/dev-requirements.in
+++ b/dev-requirements.in
@@ -15,3 +15,9 @@ icecream
click
geopandas
folium
+httpx
+fastapi
+uvicorn[standard]
+python-multipart
+jinja2
+htmx
diff --git a/dev-requirements.txt b/dev-requirements.txt
index 35a4e54..ba078b1 100644
--- a/dev-requirements.txt
+++ b/dev-requirements.txt
@@ -60,3 +60,9 @@ geopandas==1.1.0
# via -r dev-requirements.in
folium==0.19.7
# via -r dev-requirements.in
+httpx==0.26.0
+fastapi==0.97.0
+uvicorn[standard]==0.27.0.post1
+python-multipart==0.0.20
+jinja2==3.1.6
+htmx==*
diff --git a/requirements.in b/requirements.in
index 83c1ab2..e5fe3b1 100644
--- a/requirements.in
+++ b/requirements.in
@@ -14,4 +14,10 @@ timezonefinder
click
geopandas
folium
+httpx
+fastapi
+uvicorn[standard]
+python-multipart
+jinja2
+htmx
diff --git a/requirements.txt b/requirements.txt
index 42dc5c1..d202963 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -47,3 +47,9 @@ click==8.2.1
geopandas==1.1.0
folium==0.19.7
# via -r requirements.in
+httpx==0.26.0
+fastapi==0.97.0
+uvicorn[standard]==0.27.0.post1
+python-multipart==0.0.20
+jinja2==3.1.6
+htmx==*
diff --git a/src/ssoss/cli.py b/src/ssoss/cli.py
index 8e31c56..60d93bc 100644
--- a/src/ssoss/cli.py
+++ b/src/ssoss/cli.py
@@ -3,6 +3,7 @@
from . import ssoss_cli
from .signal_layer import build_signal_layer
+from .cli import review_photos
@click.group(invoke_without_command=True, add_help_option=False)
@@ -27,6 +28,7 @@ def cli(ctx, show_help):
cli.add_command(build_signal_layer)
+cli.add_command(review_photos)
if __name__ == "__main__":
cli()
diff --git a/src/ssoss/cli/__init__.py b/src/ssoss/cli/__init__.py
new file mode 100644
index 0000000..8eea58d
--- /dev/null
+++ b/src/ssoss/cli/__init__.py
@@ -0,0 +1,3 @@
+from .review import review_photos
+
+__all__ = ["review_photos"]
diff --git a/src/ssoss/cli/review.py b/src/ssoss/cli/review.py
new file mode 100644
index 0000000..4cbd252
--- /dev/null
+++ b/src/ssoss/cli/review.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+import os
+from pathlib import Path
+import click
+import uvicorn
+
+@click.command("review-photos")
+@click.option("--src", type=click.Path(exists=True, file_okay=False), required=True)
+@click.option("--port", type=int, default=8000, show_default=True)
+def review_photos(src: str, port: int) -> None:
+ """Launch photo review server."""
+ os.environ["SSOSS_PHOTO_SRC"] = str(Path(src))
+ uvicorn.run("ssoss.web.reviewer:app", host="0.0.0.0", port=port)
diff --git a/src/ssoss/web/reviewer.py b/src/ssoss/web/reviewer.py
new file mode 100644
index 0000000..4d4fb30
--- /dev/null
+++ b/src/ssoss/web/reviewer.py
@@ -0,0 +1,106 @@
+import os
+import csv
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import List, Optional
+
+from fastapi import FastAPI, Request, Form, HTTPException
+from fastapi.responses import FileResponse, HTMLResponse
+from fastapi.templating import Jinja2Templates
+from fastapi.staticfiles import StaticFiles
+import asyncio
+
+IMAGE_EXTS = {".jpg", ".jpeg", ".png"}
+
+BASE_DIR = Path(__file__).resolve().parent
+templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
+
+
+def _scan_unlabelled(src: Path, labels_csv: Path) -> List[Path]:
+ labelled = set()
+ if labels_csv.exists():
+ with labels_csv.open(newline="") as f:
+ reader = csv.reader(f)
+ next(reader, None)
+ for row in reader:
+ if row:
+ labelled.add(row[0])
+ files = []
+ for ext in IMAGE_EXTS:
+ for p in src.rglob(f"*{ext}"):
+ if p.name not in labelled:
+ files.append(p)
+ files.sort()
+ return files
+
+
+def create_app(src: Path) -> FastAPI:
+ app = FastAPI()
+ app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
+
+ @app.on_event("startup")
+ async def startup() -> None:
+ app.state.src = src
+ app.state.blocked_dir = src.parent / "blocked_signals"
+ app.state.clear_dir = src.parent / "clear_signals"
+ app.state.labels_csv = src.parent / "labels.csv"
+ app.state.blocked_dir.mkdir(parents=True, exist_ok=True)
+ app.state.clear_dir.mkdir(parents=True, exist_ok=True)
+ app.state.lock = asyncio.Lock()
+ app.state.queue = _scan_unlabelled(src, app.state.labels_csv)
+
+ @app.get("/", response_class=HTMLResponse)
+ async def index(request: Request) -> HTMLResponse:
+ async with app.state.lock:
+ path = app.state.queue[0] if app.state.queue else None
+ img_id = path.relative_to(app.state.src).as_posix() if path else None
+ return templates.TemplateResponse("index.html", {"request": request, "id": img_id})
+
+ @app.get("/image/{img_id:path}")
+ async def image(img_id: str) -> FileResponse:
+ path = app.state.src / img_id
+ if not path.exists():
+ raise HTTPException(status_code=404)
+ return FileResponse(str(path), media_type="image/jpeg")
+
+ def _move(src_path: Path, dest_dir: Path) -> Path:
+ dest = dest_dir / src_path.name
+ n = 1
+ while dest.exists():
+ dest = dest_dir / f"{src_path.stem}_{n}{src_path.suffix}"
+ n += 1
+ src_path.replace(dest)
+ return dest
+
+ @app.post("/label", response_class=HTMLResponse)
+ async def label(request: Request, id: str = Form(...), label: str = Form(...)) -> HTMLResponse:
+ async with app.state.lock:
+ match = None
+ for i, p in enumerate(app.state.queue):
+ if p.relative_to(app.state.src).as_posix() == id:
+ match = app.state.queue.pop(i)
+ break
+ if match is None:
+ raise HTTPException(status_code=404, detail="Image not queued")
+ dest_dir = app.state.blocked_dir if label == "blocked" else app.state.clear_dir
+ dest = _move(match, dest_dir)
+ write_header = not app.state.labels_csv.exists()
+ with app.state.labels_csv.open("a", newline="") as f:
+ writer = csv.writer(f)
+ if write_header:
+ writer.writerow(["photo_name", "label", "timestamp_utc"])
+ writer.writerow([dest.name, label, datetime.utcnow().replace(tzinfo=timezone.utc).isoformat()])
+ async with app.state.lock:
+ next_path = app.state.queue[0] if app.state.queue else None
+ next_id = next_path.relative_to(app.state.src).as_posix() if next_path else None
+ return templates.TemplateResponse("fragment.html", {"request": request, "id": next_id})
+
+ return app
+
+
+# For uvicorn 'ssoss.web.reviewer:app'
+_app_src = os.environ.get("SSOSS_PHOTO_SRC")
+if _app_src:
+ app = create_app(Path(_app_src))
+else:
+ app = FastAPI()
diff --git a/src/ssoss/web/static/tailwind.css b/src/ssoss/web/static/tailwind.css
new file mode 100644
index 0000000..b634b91
--- /dev/null
+++ b/src/ssoss/web/static/tailwind.css
@@ -0,0 +1 @@
+@import url("https://cdn.tailwindcss.com");
diff --git a/src/ssoss/web/templates/fragment.html b/src/ssoss/web/templates/fragment.html
new file mode 100644
index 0000000..01158b5
--- /dev/null
+++ b/src/ssoss/web/templates/fragment.html
@@ -0,0 +1,9 @@
+{% if id %}
+
+