Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions dev-requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,9 @@ icecream
click
geopandas
folium
httpx
fastapi
uvicorn[standard]
python-multipart
jinja2
htmx
6 changes: 6 additions & 0 deletions dev-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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==*
6 changes: 6 additions & 0 deletions requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,10 @@ timezonefinder
click
geopandas
folium
httpx
fastapi
uvicorn[standard]
python-multipart
jinja2
htmx

6 changes: 6 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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==*
2 changes: 2 additions & 0 deletions src/ssoss/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -27,6 +28,7 @@ def cli(ctx, show_help):


cli.add_command(build_signal_layer)
cli.add_command(review_photos)

if __name__ == "__main__":
cli()
Expand Down
3 changes: 3 additions & 0 deletions src/ssoss/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .review import review_photos

__all__ = ["review_photos"]
14 changes: 14 additions & 0 deletions src/ssoss/cli/review.py
Original file line number Diff line number Diff line change
@@ -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)
106 changes: 106 additions & 0 deletions src/ssoss/web/reviewer.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions src/ssoss/web/static/tailwind.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@import url("https://cdn.tailwindcss.com");
9 changes: 9 additions & 0 deletions src/ssoss/web/templates/fragment.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{% if id %}
<img id="photo" src="/image/{{ id }}" class="mx-auto max-h-screen" />
<div class="mt-4 flex justify-center gap-4">
<button id="block-btn" hx-post="/label" hx-vals='{"id":"{{ id }}","label":"blocked"}' class="px-4 py-2 bg-red-600 text-white rounded">🚫 Blocked</button>
<button id="clear-btn" hx-post="/label" hx-vals='{"id":"{{ id }}","label":"clear"}' class="px-4 py-2 bg-green-600 text-white rounded">✅ Clear</button>
</div>
{% else %}
<div class="text-center text-xl">All done!</div>
{% endif %}
23 changes: 23 additions & 0 deletions src/ssoss/web/templates/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
<link href="/static/tailwind.css" rel="stylesheet">
<title>Photo Review</title>
</head>
<body class="p-4">
<div id="content" hx-swap="outerHTML">
{% include 'fragment.html' %}
</div>
<script>
document.addEventListener('keydown', function(e) {
if (e.key === 'ArrowLeft') {
document.getElementById('block-btn')?.click();
} else if (e.key === 'ArrowRight') {
document.getElementById('clear-btn')?.click();
}
});
</script>
</body>
</html>
37 changes: 37 additions & 0 deletions tests/test_reviewer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import sys
import csv
from pathlib import Path
from fastapi.testclient import TestClient
from PIL import Image

root = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(root / "src"))
from ssoss.web import reviewer


def create_image(path: Path) -> None:
img = Image.new("RGB", (10, 10), color="white")
img.save(path)


def test_reviewer_flow(tmp_path):
src = tmp_path / "unclassified"
src.mkdir()
create_image(src / "a.jpg")
create_image(src / "b.jpg")

app = reviewer.create_app(src)
with TestClient(app) as client:
client.get("/")
resp = client.post("/label", data={"id": "a.jpg", "label": "blocked"})
assert resp.status_code == 200
resp = client.post("/label", data={"id": "b.jpg", "label": "clear"})
assert resp.status_code == 200

assert (tmp_path / "blocked_signals" / "a.jpg").exists()
assert (tmp_path / "clear_signals" / "b.jpg").exists()

csv_path = tmp_path / "labels.csv"
with csv_path.open() as f:
rows = list(csv.reader(f))
assert len(rows) == 3
Loading