{"id":50950746,"url":"https://github.com/sliday/graffiti-finder","last_synced_at":"2026-06-18T01:02:10.238Z","repository":{"id":361816274,"uuid":"1255933330","full_name":"sliday/graffiti-finder","owner":"sliday","description":"A small civic robot that walks Krakow through Mapillary, finds graffiti with a local vision model, and produces a form-ready list. Local-first, no live submissions by default.","archived":false,"fork":false,"pushed_at":"2026-06-01T10:38:06.000Z","size":59884,"stargazers_count":2,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-01T12:24:00.171Z","etag":null,"topics":["apple-silicon","arcgis","civic-tech","clip","computer-vision","graffiti","krakow","mapillary","mlx","sam3"],"latest_commit_sha":null,"homepage":"https://krakow-graffiti.pages.dev","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/sliday.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-01T09:58:21.000Z","updated_at":"2026-06-01T11:01:34.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/sliday/graffiti-finder","commit_stats":null,"previous_names":["sliday/graffiti-finder"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/sliday/graffiti-finder","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sliday%2Fgraffiti-finder","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sliday%2Fgraffiti-finder/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sliday%2Fgraffiti-finder/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sliday%2Fgraffiti-finder/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/sliday","download_url":"https://codeload.github.com/sliday/graffiti-finder/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sliday%2Fgraffiti-finder/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34471639,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-17T02:00:05.408Z","response_time":127,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["apple-silicon","arcgis","civic-tech","clip","computer-vision","graffiti","krakow","mapillary","mlx","sam3"],"created_at":"2026-06-18T01:02:09.128Z","updated_at":"2026-06-18T01:02:10.229Z","avatar_url":"https://github.com/sliday.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# graffiti-finder\n\nA small civic robot that walks a city through open street imagery, finds graffiti\non walls with a local vision model, and produces a form-ready list of locations\n+ addresses + crops that a human can review before any submission to the city.\n\nBuilt for Kraków against the city's Survey123 form\n([`Gravvitti_v_1`](https://survey123.arcgis.com/share/b996fb8c744f41a69c5d51729702c55b?portalUrl=https://bezpiecznie.um.krakow.pl/portal),\nowned by Wydział Bezpieczeństwa i Zarządzania Kryzysowego UMK), but every layer\nis parameterised — point it at any city with Mapillary coverage and a public\nArcGIS / open-form endpoint.\n\n**Live demo:** \u003chttps://krakow-graffiti.pages.dev/\u003e\n([interactive map](https://krakow-graffiti.pages.dev/map) ·\n[writer clusters](https://krakow-graffiti.pages.dev/writers.html))\n\n![status](https://img.shields.io/badge/status-working-c9f76f)\n![python](https://img.shields.io/badge/python-3.12-blue)\n![runs on](https://img.shields.io/badge/runs%20on-Apple%20Silicon%20%2B%20MLX-black)\n![license](https://img.shields.io/badge/license-MIT-lightgrey)\n\n---\n\n## What it does, in one diagram\n\n```\nMapillary Graph API  ──▶  download every image in bbox (tile-by-tile)\n                          │\n                          ▼\n              SAM3 (Apple-MLX port)            ──▶  text-prompted boxes\n                          │\n                          ▼\n              CLIP zero-shot 2nd-stage         ──▶  rejects road signs, lamps,\n                          │                          windows, billboards, trees\n                          ▼\n              geometric gates                  ──▶  aspect ratio, area, std\n                          │\n                          ▼\n              spatial + pHash dedup            ──▶  one row per wall\n                          │\n                          ▼\n              Krakow GIS enrichment           ──▶  district, police region,\n                          │                          parcel + building IDs,\n                          │                          street + house no.\n                          ▼\n              OSM Overpass heritage match     ──▶  flag if on monument\n                          │                          (criminal in PL, not just\n                          │                          a misdemeanour)\n                          ▼\n              CLIP image-embedding cluster    ──▶  candidate writer_id —\n                          │                          spot serial offenders\n                          ▼\n              SQLite queue + Google Sheet + interactive Leaflet map\n                          │\n                          ▼\n              (optional, double-gated) anonymous POST\n              to ArcGIS FeatureServer addFeatures + addAttachment\n```\n\n## What it produced (Kraków, May 2026)\n\n| | |\n|---|---|\n| Tiles walked (0.009° each) | 80 |\n| Mapillary images processed | 1 800+ |\n| Districts covered | **12** (Stare Miasto, Krowodrza, Grzegórzki, Podgórze, Prądnik Czerwony, Nowa Huta, Zwierzyniec, Dębniki, Bieńczyce, Wzgórza Krzesławickie, Prądnik Biały, Czyżyny) |\n| Form-ready detections after freshest-per-wall prune | **622** |\n| Unique geocoded addresses | 184 |\n| On monuments (criminal-grade) | **77** — Pałac Popielów (12), Kamienica Mennica (8), Kamienica Dawidowska (5)… |\n| Candidate writers (≥3 visually-similar crops) | 33 |\n| Live submissions to the city form | **0** |\n\nThe pipeline runs end-to-end on a single Apple Silicon Mac, no GPU server, no\ncloud inference. The full city-wide sweep takes about an hour wall-time.\n\n## Why \"no live submissions\"\n\nEvery report dispatches a real cleanup crew. We default to **dry-run + human\nreview** because (a) false positives have real cost to the city, (b) the\n`deleteFeatures` operation is disabled on the city endpoint — once a row goes\nin, it stays. The CLI requires *both* `--live` AND `--confirm-token=WYSLIJ` to\nactually POST. Anything short of that is dry-run.\n\n## Architecture by module\n\n```\nsrc/krakow_clean/\n├── walker.py        Mapillary Graph API search + retry-on-500\n├── vision.py        GroundingDINO + CLIP fallback (open weights, no auth)\n├── vision_sam3.py   SAM3 + CLIP hybrid via Python 3.13 subprocess sidecar\n├── enrichment.py    Kraków GIS: district, police region, parcel, geocoder\n├── formspec.py      XForm payload builder for the OpenRosa fallback path\n├── submit.py        FeatureServer addFeatures + addAttachment (double-gated)\n├── browser_fill.py  Playwright dry-fill demonstration (never clicks Wyślij)\n├── dedup.py         SQLite WAL store + pHash + spatial dedup\n├── pipeline.py      Single-route walk-and-detect orchestration\n├── routes.py        Named waypoint lists + city bboxes\n├── config.py        .env loader, Survey123 + Mapillary endpoints\n└── cli.py           Typer entry points: walk, mock, status, submit, probe\n\nscripts/\n├── walk_full_sam3.py        Full pipeline on a route (download + SAM3 + CLIP)\n├── grid_walk.py             bbox tiler, walks every tile in parallel\n├── parallel_walk.py         ProcessPool fan-out, N workers\n├── prune_stale.py           collapse a wall's older captures, keep freshest\n├── prune_close.py           drop near-duplicate crops in tight buckets\n├── check_monuments.py       OSM Overpass → mark on-monument detections\n├── cluster_writers.py       CLIP image embeddings → agglomerative cluster\n├── build_writer_gallery.py  per-cluster crop wall for visual confirmation\n├── build_map.py             interactive Leaflet map with monument toggle\n├── build_public.sh          Cloudflare Pages deploy directory builder\n├── export_sheet.py          Google Sheet via gws CLI\n└── (and ~10 more recon / visualisation utilities)\n```\n\n## How to run it yourself\n\n### 0. Prerequisites\n\n- macOS with Apple Silicon (M1 / M2 / M3 / M4) — MLX SAM3 needs Metal.\n  CPU fallback works on Linux but is much slower.\n- Python 3.12 (project) + Python 3.13 (sidecar for mlx-sam3, installs side-by-side via `uv`).\n- ~12 GB free disk (model weights ~7 GB + Mapillary cache scales with tiles walked).\n- A Mapillary account + access token (free, sign up at\n  [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers)).\n\n### 1. Clone + install\n\n```bash\ngit clone https://github.com/sliday/graffiti-finder.git\ncd graffiti-finder\nuv sync                                 # 3.12 env with torch / transformers / etc\ncp .env.example .env                    # then fill in your Mapillary token\n```\n\n### 2. Clone vendored submodules\n\nTwo upstream models live outside this repo:\n\n```bash\nmkdir -p vendor \u0026\u0026 cd vendor\ngit clone --depth 1 https://github.com/facebookresearch/sam3.git\ngit clone --depth 1 https://github.com/Deekshith-Dade/mlx_sam3.git\ncd mlx_sam3 \u0026\u0026 uv sync                  # creates the parallel Python 3.13 env\ncd ../..\n```\n\nThe pipeline shells out to `vendor/mlx_sam3/sidecar.py` for SAM3 inference;\nthe main project never has to load that runtime in-process. See\n[`docs/SAM3_ATTEMPTS.md`](docs/SAM3_ATTEMPTS.md) for the full chronology of\nwhy we ended up with this two-env arrangement.\n\n### 3. Quick smoke test\n\n```bash\n# Mapillary coverage on a Kraków corridor, no detection\nuv run krakow-clean probe --route karmelicka --min-year 2024\n\n# Full pipeline on one route (~7 min, downloads ~150 images)\nuv run python scripts/walk_full_sam3.py karmelicka\n\n# Render the proposed list as JSONL + the local map\nuv run krakow-clean mock --limit 1000\nuv run python scripts/build_map.py\nopen demo/map.html\n```\n\n### 4. Wider sweep\n\n```bash\n# Inner Kraków, 80 tiles, 3 workers, ~30 min\nuv run python scripts/grid_walk.py inner-krakow --workers 3 --tile-cap 25\n\n# Then drop duplicate captures of the same wall (keep freshest)\nuv run python scripts/prune_stale.py\n\n# Heritage matching: 1,400+ OSM features → flag criminal-grade detections\nuv run python scripts/check_monuments.py\n\n# Writer clustering: CLIP embeddings → agglomerative cluster\nuv run python scripts/cluster_writers.py --threshold 0.10\n\n# Rebuild map + Sheet + landing page\nuv run python scripts/refresh_artifacts.py\nuv run python scripts/build_writer_gallery.py\n```\n\n### 5. (Optional) deploy\n\n```bash\nbash scripts/build_public.sh\nwrangler pages deploy public --project-name=\u003cyour-project\u003e --branch=main\n```\n\n## Privacy + safety\n\nEverything runs locally. The pipeline never sends imagery to a third party for\nanalysis. Outbound calls and their payloads are documented in\n[`docs/PRIVACY.md`](docs/PRIVACY.md). Quick summary:\n\n| Outbound | What goes there | Why |\n|---|---|---|\n| Mapillary Graph API | bbox + access token | the imagery source |\n| HuggingFace Hub | weight downloads (cached) | one-time SAM3 + CLIP fetch |\n| Kraków GIS endpoints | lat/lng (in imagery already) | civic enrichment lookups |\n| OSM Overpass | one bbox query, ~5s | heritage feature catalogue |\n| Google Sheets API | the form-ready table | optional, only when you export |\n| Cloudflare CDN tiles | nothing about detections | map tile rendering, browser-side |\n| **City form endpoint** | the report payload + crop | **only if you authorise `--live`** |\n\nNo detection metadata, no per-user identifier, no analytics — nothing about\nyou or your detections leaves the laptop unless you explicitly call the submit\ncommand with both gates set.\n\n## Key findings (engineering notes)\n\nIf you're cloning this to build something similar, the non-obvious gotchas\nthat took the longest to find:\n\n- **Krakow's geocoder** needs `location={\"x\":lng,\"y\":lat,\"spatialReference\":{\"wkid\":4326}}` —\n  the `x,y` shorthand returns \"empty geometry\". Two hours.\n- **The Survey123 form definition lies about CAPTCHA.** XForm metadata says\n  `captcha.isEnabled=false`, the live webform shows an image CAPTCHA mid-fill.\n  The FeatureServer REST endpoint bypasses it — that's the only programmable\n  submission path.\n- **`deleteFeatures` is disabled** on `Graffitti_v1_2/FeatureServer/0` even\n  though `updateFeatures` works. Plan submissions as immutable; use `update`\n  to retract.\n- **OSM Nominatim returns wrong addresses at corner buildings.** Three streets\n  meet at the Bar Mleczny corner; OSM picks the nearest house number, which\n  isn't the one on the visible facade. Kraków's own Lokalizator agrees with\n  Google. Use the city geocoder, not OSM, for civic-facing reports.\n- **SAM3's official PyTorch path won't run on Apple Silicon** without invasive\n  patching (triton + 9 files of hardcoded `cuda` + bf16 mismatch). The\n  `mlx-community/sam3-image` Python 3.13 port works out of the box.\n- **`replace_all` on bare numbers corrupts image filenames.** A queue-count\n  bump from 471 to 622 silently rewrote `sample_02_792471472888873.jpg` to\n  `sample_02_792622472888873.jpg`. Anchor every numeric replace to its\n  surrounding context.\n\n## Tech stack\n\n- **Vision:** [SAM3](https://github.com/facebookresearch/sam3) text-prompted\n  segmentation (via [Deekshith-Dade/mlx_sam3](https://github.com/Deekshith-Dade/mlx_sam3) for Apple Silicon)\n  + [openai/clip-vit-base-patch32](https://huggingface.co/openai/clip-vit-base-patch32)\n  second-stage classifier\n- **Imagery:** [Mapillary](https://www.mapillary.com/developer/api-documentation) Graph API\n- **Civic data:** Kraków GIS (`bezpiecznie.um.krakow.pl/portal`,\n  `msip.um.krakow.pl/arcgis`) + [OpenStreetMap Overpass](https://wiki.openstreetmap.org/wiki/Overpass_API)\n- **Form submission (research-only):** ArcGIS Survey123 + FeatureServer REST\n- **Browser dry-fill demo:** [Playwright](https://playwright.dev)\n- **Map UI:** [Leaflet](https://leafletjs.com) + [leaflet.heat](https://github.com/Leaflet/Leaflet.heat),\n  CARTO dark tiles\n- **CLI:** [Typer](https://typer.tiangolo.com) + [Rich](https://github.com/Textualize/rich)\n- **Storage:** SQLite (WAL mode for parallel walkers)\n- **Hosting:** Cloudflare Pages\n- **Spreadsheet export:** [`gws`](https://github.com/googleworkspace/cli) Google Workspace CLI\n\n## License\n\nMIT. See [LICENSE](LICENSE).\n\n## Author\n\nStas Kulesh · [stas@sliday.com](mailto:stas@sliday.com) · [@sliday](https://github.com/sliday)\n\nBuilt with [Claude Code](https://claude.com/claude-code) for the Kraków city\ngraffiti-reporting form. The full session transcript and design specs live in\n[`docs/`](docs/).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsliday%2Fgraffiti-finder","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsliday%2Fgraffiti-finder","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsliday%2Fgraffiti-finder/lists"}