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 %} + +
+ + +
+{% else %} +
All done!
+{% endif %} diff --git a/src/ssoss/web/templates/index.html b/src/ssoss/web/templates/index.html new file mode 100644 index 0000000..dce8e6f --- /dev/null +++ b/src/ssoss/web/templates/index.html @@ -0,0 +1,23 @@ + + + + + + + Photo Review + + +
+ {% include 'fragment.html' %} +
+ + + diff --git a/tests/test_reviewer.py b/tests/test_reviewer.py new file mode 100644 index 0000000..5e46a36 --- /dev/null +++ b/tests/test_reviewer.py @@ -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