geo-sleuth
Geolocate or chronolocate a photo with tool-verified reasoning (where was this taken / when was it taken / photo geolocation / geo-guessing). Given one or more photos, intake.py does metadata, OCR and reverse image search (Baidu/Yandex) in one command; clues and candidates go on
Install
npx skills add https://github.com/Oldcircle/geo-sleuth/tree/main/skills/geo-sleuth
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install oldcircle-geo-sleuth@llmmart
git clone https://github.com/Oldcircle/geo-sleuth.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole oldcircle/geo-sleuth collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
geo-sleuth (v2)
Goal: find a location that holds up under checking. Every conclusion must point to the clue it used, the command it ran and the file it produced.
${CLAUDE_SKILL_DIR}in commands means the directory containing this SKILL.md (the same for commands in references). Claude Code substitutes it automatically; in other agents, firstexport CLAUDE_SKILL_DIR=<absolute path of this directory>before running (if the shell doesn't keep variables between commands, prefix every command with it), or replace it with that path directly.
The work is split into three layers. Remember this first, then read the flow:
| Layer | Who | Tools |
|---|---|---|
| Decision: which candidates exist, how evidence is scored, whether a candidate can be excluded, what to scan next | Script (candidate board) | board.py |
| Perception: reading text, table lookups, finding targets in satellite imagery, street-view matching | Scripts compute and rank first; you look only at the top few | intake.py ocr.py clues.py sat_scan.py match.py geo.py bearings |
| Judgment: pulling clues out of the image, proposing hypotheses when the tables have nothing, deciding among the machine's top few | You | — |
The method comes from breaking down 14 photo-geolocation (网络迷踪) creator videos and 22 puzzles, plus several rounds of blind-test comparisons; the breakdown notes are not published with the repo. The lesson of v1: rules written as prose don't get executed, and two runs of the same skill version gave very different results; so v2 puts every rule that can be written as code into board.py.
Hard rules (always in force; ★ = enforced by board.py, just follow it)
- If the user says the photo isn't theirs, or the image shows a private residence or minors, ask once what it's for before continuing; when the prompt already states the source or purpose (an evaluation puzzle, a puzzle setter's hint, the user says they took it), don't ask, and go to the finest level as usual.
- Don't fabricate verification. "Measured on the map", "matched street view", "±10 m" must correspond to commands actually run and files actually produced in this session. If you didn't run it, write "unverified".
- Precision needs a source: an error radius ≤100 m requires the intersection of two independent constraints, or a ground-level match on ≥3 invariant features. Before reporting a camera position, run a self-check:
pose.py project --horizon <sea-horizon row>computes what the pitch should be and each feature's "expected row" for the current camera position; reconcile that with the photo (the depression angle to a target ≠ the camera's pitch; look up the camera position's elevation withterrain.py elev— if the height difference doesn't match the depression angles in the image, the camera position is wrong). - Once you have a unique anchor, close the loop: all later search and geometry starts from the anchor; don't go back to generic features like "blue-green lake water" or "tropical park" and pick the most famous place of that kind.
- Compute bearings first, then identify structures: which block in satellite imagery is the tower or chimney in the photo is itself an interpretation. First compute its true bearing in the image with
sun.py compassor a confirmed landmark, then usegeo.py bearingsto see which outline around the candidate camera position falls on that bearing; when nothing fits, first try both templates ("original/mirrored", "sunrise/sunset"); if not verified, only lower the tier, don't exclude. - ★ List the whole category before inferring: when inferring a region from "hill city", "tropical", "IP in a municipality", use
board.py childrento add all subordinate admin divisions as candidates, then rank them with evidence. Don't default to the main urban district, don't pick by population. - Metadata and hints are hypotheses: when EXIF, IP location, location tags or the puzzle setter's words conflict with the image, the image wins; don't invent a story to reconcile them. IP says country A but the hint or the regulation designs (plates, signs) point to another continent: intersect with
clues.py lookup territories <country A> --continent <continent>. - Self-consistency: redo each key judgment once with a different crop or keyword set; if the two results differ by tens of kilometers or more, lower the tier.
- ★ If you can't pin a point, give a range + what information is missing; when the candidates are a few discrete ones, don't take the midpoint: the main answer of
board.py reportis always the top-ranked candidate; the rest go into alternatives with discriminating tests. - ★ Exclusion and confirmation use the same standard:
board.py excludeonly accepts read/computed clues + a computed file (output ofgeo.py frame,terrain.py, etc.); observed and inferred clues can only down-weight viaevidence --against, with the likelihood ratio clamped to 1/3–3 (inferred) or 1/5–5 (observed). Generating candidates from "there's an X nearby" is also filtering, and X must be something confirmed in the image. Fine-level candidates such as campuses, residential compounds and street segments must go on the board too: add a batch at once withboard.py add --from <poi.py --out / osm.py geom output> --level area/road, don't hand-pick; when you drop one, writeevidence --againstwith a comparison image, andchecklists candidates without a single piece of evidence (meaning nobody looked at them); a finer candidate goes under the one that contains it (a point under its street segment:--parent, orboard.py move), so its support counts in that level's ranking, andcheckfails when it skips a level; labels from third-party data (tree species in a municipal inventory, an exact school-name match) can only down-weight, never justify exclusion (in two cases the ground truth was skipped by hand exactly this way). The excluded extent must be ≤ the evidence extent: for candidates with extent, such as districts, areas and roads,excludeneeds--covers lat,lon[:lat,lon]stating which stretch the evidence covers; the script rejects coverage below half (excluding a whole road after looking at one point on it failed in two cases). - ★ Population and fame are not evidence; scan order follows "share ÷ pages" (
board.py next): finish the small districts first, put large districts last with a page cap.
Flow
You don't have to go through every step: if reverse image search in step 1 hits directly, jump to steps 6 and 7 to confirm. Every step's output goes on the candidate board.
Step 1: steps 0–3 in one command
uv run ${CLAUDE_SKILL_DIR}/scripts/intake.py photo.jpg --out-dir intake/ [--box x0,y0,x1,y1 ...]
uv run ${CLAUDE_SKILL_DIR}/scripts/board.py init --photo photo.jpg
intake.py usually takes 1–2 minutes, longer on the first run because it installs dependencies: give the command a generous timeout (10+ minutes) or run it in the background. If a command timeout interrupts it, the reverse-image-search subprocesses may still be writing into rev/, but intake.md won't be generated; don't treat it as finished.
intake.md contains: metadata, OCR text (lines read from upscaled/tiled passes are marked pass=up/tile and are hypotheses), Baidu similar images (source-site counts + numbered contact sheet), reverse-image-search labels with tiered vote counts, likely residential compound/development names, the list of edge crops, and failed items. Then you do four things:
- Look at the image: go through every crop in
edges/(four edges, four corners); run through the checklist inreferences/observe.md; log each clue withboard.py clue "<text>" --kind <kind> --status observed|read|inferred|computed --file <zoomed crop>. Be honest about status: text you read is read; "the building is probably 8 floors", "the road goes uphill" are inferred. - When a computed result actually tests an earlier clue, link it with
clue "<result>" --kind <kind> --status computed --file <output> --resolves K1(repeat--resolvesfor multiple clues). An unrelated computation does not resolve a pending infrastructure clue; this link records the test, not support for a location. - Lookup tables: calling codes, driving side, overseas territories are global; plates, area codes and other country tables come from region packs →
clues.py lookup <kind> <value> [--country <cc>](without--country, every pack is searched and matches from several countries are all returned). For those that resolve to an admin division, useboard.py apply --kind plate --value <plate prefix> --file <zoomed crop>directly (adds candidates and evidence automatically; the other candidates at the same level are only down-weighted, not excluded). - Reverse-image-search results: first open
rev/<name>_baidu_similar.jpg(the top-left tile is the query image) and look for near-duplicates of the same object or the same scene; if there are any, go by number tosimilar[i].fromin the JSON to see the source page; compound, development or hotel names in the labels →poi.py "<name>" --city <city> --out pois.jsonto get coordinates; for same-name hits (several campuses, several branches) put them all on the board withboard.py add --from pois.json --level area, then check; always open the screenshots, and once a post hits, look through the rest of its photo set. Choose where to search by object type (references/search.md). - Hints and metadata: log each as an inferred clue and state how credible it is; IP location only says where the person was when posting, and the posting time is not the capture time.
- Country-specific material: country-specific clues, tables, preferred services and tips are in region packs (
regions/<cc>/). Once a country is a candidate, runregions.py show <cc>: it prints the pack's lookups, services and a one-line index of its clue entries with line numbers; read only the entries you need. When the country is settled,board.py country <cc>.regions.py listshows which packs exist; with no pack, use the global tools andreferences/clues/global.md.
Step 2: list all candidates, score evidence, check the next step
uv run ${CLAUDE_SKILL_DIR}/scripts/board.py children <parent admin area> # municipality → all districts; country → first-level admin divisions (gazetteer.py queries OSM, with bbox)
uv run ${CLAUDE_SKILL_DIR}/scripts/board.py evidence --clue K1 --for <candidate A>:5 --for <candidate B>:2 --against <candidate C>:0.3 --why "…" --file <comparison image>
uv run ${CLAUDE_SKILL_DIR}/scripts/board.py rank
uv run ${CLAUDE_SKILL_DIR}/scripts/board.py next
next only ever says one of two things:
- Can't separate: run discriminating tests from cheap to expensive, each one on all candidates at once — lookup tables → terrain (plain vs. hill city,
tiles.py fetch --zoom 13orterrain.py) → vehicle livery (image search for " - Ready to narrow: first narrow the extent to the built-up area with
board.py urban <candidate>(or give it by hand withscan-bbox), then write a falsification condition withboard.py falsify <candidate> --text "…", then go to step 3.
The coarse environmental-location rules still apply: terrain before river width and building color; phenology must be paired with the month; take the intersection of rare facility combinations; recognizable species are only an exclusion tool; when all you have is a land type, first narrow to that type of land with a land-cover map (references/clues/).
Step 3: pick a branch to narrow down
| What you have | Approach | Read |
|---|---|---|
| A unique anchor (building, statue, tower, scenic-area building) | Anchor geometry: sight-line intersection, alignment lines, tangent lines, filtering buildings by camera height | geometry.md |
| Distant mountains, skyline | Render candidate camera positions with terrain.py view and compare; this fixes only one sight line, then find a second constraint. No EXIF: don't assume the main camera (distant mountains are often zoomed); a best focal on the edge of the searched range means widen and rerun. Near range in front of a far one: score both layers |
geometry.md |
| Clear shadows, lit faces, the sun in the frame | sun.py locate / when / facing / compass: latitude band, time, heading, true bearing of objects in the image |
sky.md |
| Route numbers, railway or power-line specs, a large river | Linear corridor + line-to-point: osm.py route / crossings / along |
corridors.md |
| Two or three kinds of infrastructure in one frame | osm.py near --report; crossings of two linear types osm.py intersect (bends are only annotated, not removed) |
corridors.md |
| Linear infrastructure + unrecognized mountains, no text | Compute horizons from elevation along the infrastructure and filter a whole region → score skyline + distance to infrastructure numerically → overlay the top 3–5 → fix the camera position from evenly spaced structures: terrain.py scan --lines … --out hits.json --clusters-out clusters.json → terrain.py ridge photo.jpg --x0 … --x1 … --out ridge.json → terrain.py fit --hits hits.json --ridge ridge.json [--line … --line-dist …] --sheet top.jpg --photo photo.jpg (for the fine search switch to --at lat,lon --radius 800 --grid 100 --zoom 13) → imgprep.py piers photo.jpg --rows … --out cols.json --sheet piers.jpg → geo.py spacing --cols … --line … --span … --center lat,lon --ridge ridge.json |
corridors.md 4.3, geometry.md 7.4 / 7.7 |
| Clear street layout, no anchor | osm.py street-scan to filter intersections → tiles.py sheet → street view |
corridors.md |
| No near-duplicates from reverse image search, no text, only a set of scene elements (road spec + adjacent features + landform) | Don't hand-pick spots first: write the scene as one English query and let CLIP rank with sat_scan.py grid --bbox <coastal strip/corridor> --zoom 17 --cell 360 --query … --neg …; look only at the top 20–30 thumbnails; grid beats points because OSM features like amenity=parking may be incomplete |
search.md |
| ≥4 points of known location in the frame (window views, looking down; also level shots of distant towers, bridges, piers — fix the height) | pose.py solve solves camera position, heading and height; when several candidate positions all fit, score them one by one with pose.py check --cands; only robust Δchi2 > 9 may go to board.py exclude --computed |
geometry.md section 10 |
| Want to exclude by "there's no X in the frame" | geo.py frame first computes whether X should be inside the frame, large enough and not occluded; only what it says can exclude goes to board.py exclude --computed |
geometry.md section 11 |
| An unnamed building of a recognizable class, search region narrowed | Enumerate with osm.py find → nearby views facing each building with gsv.py targets → rank with match.py; use bounded batches |
search.md section 6 |
| Recognizable facility type (grain dryer tower, feed mill, sugar mill, concrete batching plant) | Look up the industry's distribution → enumerate large buildings with osm.py buildings → rank with sat_scan.py points |
search.md |
| Stores of a chain's sub-brand | First search opening press releases for the address; use store locators only as a candidate pool → osm.py along + street-view sampling |
search.md |
| Plane window, drone shots looking down | Aerial branch | aerial.md |
Step 4: finding the spot in satellite imagery — the machine ranks first
uv run ${CLAUDE_SKILL_DIR}/scripts/sat_scan.py grid --bbox <scan_bbox> --zoom 17 --preset track --multi-scale --top 30 --out sat.json --sheet sat_top.jpg --heat heat.jpg
uv run ${CLAUDE_SKILL_DIR}/scripts/poi.py "<district> <school, in the local language>" --city <city> --region <cc> --out schools.json # seeds: --seeds schools.json boosts nearby cells
uv run ${CLAUDE_SKILL_DIR}/scripts/sat_scan.py points --points big.json --preset factory --out r.json --sheet r.jpg # rank candidate points from osm.py buildings / poi.py
- Presets: track (running tracks), stadium, factory, silo, dam, bridge, quarry, solar, greenhouse, port; custom
--query. In tests: across 300-odd cells of an urban area, more than half of the OSM-mapped running tracks made the top 30; in an old town in China where OSM is blank, a school's sports field still ranked first. It ranks, it doesn't decide: look at the thumbnails of the top 20–30 cells, then check them against the bearing and shape in the photo. - Translate the image description into top-down features before looking: curved buildings, octagonal pavilion roofs, sports courts, parking spaces perpendicular to a railway; for tall buildings look at the base; imagery has a date.
- With many candidates, make a candidate table: one row per point, one column per criterion directly visible in the photo.
- Nothing in the top 30 matches: first go back to the "candidates down-weighted by inference" listed by
board.py checkand to the falsification conditions, then switch preset or go to z18, and only then widen the area.
Step 5: confirmation — street view is ranked first too
uv run ${CLAUDE_SKILL_DIR}/scripts/baidu_pano.py scan <lat,lon> --radius 300 --out panos.json # the street-view script the region card names (CN: baidu_pano.py); otherwise gsv.py
uv run ${CLAUDE_SKILL_DIR}/scripts/match.py rank --query photo.jpg --panos panos.json --toward <landmark lat,lon> --spread 15 --refine sift --top 10 --out m.json --sheet m.jpg
uv run ${CLAUDE_SKILL_DIR}/scripts/match.py rank --query photo.jpg --items around.index.json --render gsv --spread-headings -30,0,30 --out m.json --sheet m.jpg
match.pycoarse-ranks by DINOv2 global similarity and fine-ranks by SIFT inliers; in tests, ground-truth street views of the same place from different years all made the top 4. Open only the top 10 and compare invariant features (building outline, window positions, balconies, pole positions, curbs, ridgelines), not vehicles, signs or foliage. ≥2 features for road level, ≥3 for building level. Not a single image with ≥15 inliers doesn't mean it's wrong: with a change of season, an old capture, or the photo taken from the sidewalk while street view was shot from the middle of the road, the ground truth had only 5 and 0–8 inliers in tests (two cases); first open the top 10 and compare invariant features, and only if none fit change--spread-headingsor widen--within. The global score gets dominated by season (a blossom-season capture ranks high wherever it is), so compare against a historical capture from the same season as the photo when you can (gsv.py sheet --date).- With many panorama points, group by date and road;
sheet --road <road name> --spread 60shows one road only; older captures have a more open view. - No street view doesn't mean you can't confirm: compare ridgelines with
terrain.py view --photo; find a name for the candidate facility (OSM name, nearby place name + the facility-type word in the local language) and search news, encyclopedias and official-site photos to compare facade details. - When street view is many years older than the photo, go by old buildings and permanent structures.
Step 6: fix the camera position and write the output
- Look back: at the matching street-view point, turn 180° and see what's on the photographer's side.
- A camera position needs two independent constraints (
geo.py intersectsight-line intersection,geo.py linealignment line,pose.pymulti-point solve, camera height, reverse street view); with only one, building-level confidence is at most "medium". - When there's no measurable shadow, use lit faces for the heading:
sun.py facing --lit left --shaded camera. For photos taken from a car, boat or train, state the direction of travel.
uv run ${CLAUDE_SKILL_DIR}/scripts/board.py check # before concluding: do exclusions have files, does the main answer have verified evidence, which clues went unused
uv run ${CLAUDE_SKILL_DIR}/scripts/board.py report --merge result.json # main answer = top-ranked; alternatives, exclusions and unused clues are written into result.json automatically
uv run ${CLAUDE_SKILL_DIR}/scripts/evidence.py spec.json --out evidence.jpg
Show evidence.jpg to the user together with the answer, without waiting to be asked. A road-level claim from satellite imagery needs a ≥z18 crop, not a z17 glance.
Output:
- One-sentence conclusion: place + camera position + heading (+ direction of travel and capture time, where applicable)
- Coordinates: WGS84, plus the local datum when the region card names one (
geo.py convert), with error radius - Evidence image (satellite image marking the camera position and heading wedge + comparison images)
- Reasoning chain: clue → inference → commands actually run and files produced → extent
- Tiered confidence (table below). Rate each level separately; the tier you report is the finest level rated "medium" or above: a low building level doesn't drag down a high area level
- Excluded candidates and reasons, alternatives and discriminating tests, clues not used or not resolved (generated by
board.py report) - When you can't pin a point: which level is settled + what information is still needed
Use English for tool messages, report headings and generated labels. Preserve source text (OCR, place names, service responses) verbatim as evidence; explain or translate it for the reader. Write the agent's final report in the user's language. In Chinese, use 高/中/低 for high/medium/low and 城市/片区/路/楼/楼层 for city/area/road/building/floor.
| Level | High | Medium | Low |
|---|---|---|---|
| City | Text, plates, area codes or a confirmed anchor | Several independent weak clues agree | A single weak clue, or only the hint |
| Area | A unique facility or landmark matches ≥3 top-down features in satellite imagery (whole candidate area checked, no second match) | The most similar spot in the candidate area, with candidates still unchecked | Inferred |
| Road | Ground-level match on ≥2 unique features | Layout consistent, 1 unique feature; when there is no ground-level imagery at all: area level high + bearing self-check passed | Satellite layout only |
| Building | Two independent constraints + ground-level match on ≥3 features | One of the two | Inferred |
| Floor | Two kinds of reference agree | A single reference | Don't give |
Budget and stopping conditions
intake.pycounts as one call; if reverse image search with 3 different keyword sets finds nothing, stop and go back to the checklist for other clues.- While candidates still can't be separated, don't scan any point one by one (when
board.py nextsays "can't separate", do the discriminating tests first); if they still can't be separated after the cheap tests, scan in the order it gives, the first 3 pages of each candidate's built-up area first. - Scanning and confirmation always rank first: look at the top 30 cells from
sat_scan.pyand the top 10 images frommatch.py; widen only when all of the top ones are wrong, instead of paging through. - Run
osm.py coveragebefore enumerating candidates with OSM: districts with sparse data get silently excluded; switch tosat_scan.py gridand say so in the conclusion. - Scan panoramas in batches of at most 500 points per area. When a batch or several areas yield no match, review candidate generation, search coverage and down-weighted candidates; then change the approach or widen the justified search scope. A fixed number of unmatched areas is not a reason to end the investigation.
- A failed or unavailable service blocks that route, not the whole investigation. Consider other feasible sources or methods; do not repeat unchanged failing requests.
- Before stopping,
board.py check: go back through the top 20 candidates, in ranking order, that were down-weighted by inference but not excluded. - Conclude when evidence supports the requested location level, the user asks to stop or a user-set budget is reached, or no remaining feasible action is likely to distinguish the candidates. If unresolved, use
board.py reportto state the supported level, actual search coverage, attempted routes, blockers and missing evidence; do not invent a coordinate or precision to finish.
Runtime environment
- Python 3.10+,
uv, andcurl. Always useuv run ${CLAUDE_SKILL_DIR}/scripts/xxx.py; each script declares its dependencies.scripts/in references is relative to this skill directory. - On first setup or after a runtime failure, run
uv run ${CLAUDE_SKILL_DIR}/scripts/doctor.py; add--networkto check service reachability. Read the English checks and fixes before starting an expensive scan. It uploads no photos and does not load ML models. - Reverse image search uses local Google Chrome, with automatic fallback to Playwright Chromium (
uvx playwright install chromium). If neither starts, useintake.py --no-revand report the skipped search. OCR prefers Apple Vision on macOS and uses RapidOCR elsewhere or as a fallback. match.pyandsat_scan.pyinstall ML dependencies and download model weights on first use. Allow extra time and disk space.- Cache goes to
.geo-cache/in the current directory; the candidate board isboard.jsonin the current directory. Script list and data sources:references/data-sources.md. - macOS has no
timeoutcommand; in zsh$vardoesn't word-split, so usebash -cor${=var}in loops. A province-wide Overpass query can take several minutes; run it in the background.
Files (geo-sleuth)
-
data
-
calling_codes.json 20.5 KB
{ "_meta": { "source": [ "https://en.wikipedia.org/wiki/List_of_telephone_country_codes" ], "fetched": "2026-09-14", "count": 281 }, "7-840": [ { "country": "Abkhazia", "utc": "+03:00" } ], "7-940": [ { "country": "Abkhazia", "utc": "+03:00" } ], "93": [ { "country": "Afghanistan", "utc": "+04:30" } ], "358-18": [ { "country": "Åland", "utc": "+02:00" } ], "355": [ { "country": "Albania", "utc": "+01:00" } ], "213": [ { "country": "Algeria", "utc": "+01:00" } ], "1-684": [ { "country": "American Samoa", "utc": "−11:00" } ], "376": [ { "country": "Andorra", "utc": "+01:00" } ], "244": [ { "country": "Angola", "utc": "+01:00" } ], "1-264": [ { "country": "Anguilla", "utc": "−04:00" } ], "1-268": [ { "country": "Antigua and Barbuda", "utc": "−04:00" } ], "54": [ { "country": "Argentina", "utc": "−03:00" } ], "374": [ { "country": "Armenia", "utc": "+04:00" } ], "297": [ { "country": "Aruba", "utc": "−04:00" } ], "247": [ { "country": "Ascension", "utc": "+00:00" } ], "61": [ { "country": "Australia", "utc": "+08:00 to +10:30" } ], "43": [ { "country": "Austria", "utc": "+01:00" } ], "994": [ { "country": "Azerbaijan", "utc": "+04:00" } ], "1-242": [ { "country": "Bahamas", "utc": "−05:00" } ], "973": [ { "country": "Bahrain", "utc": "+03:00" } ], "880": [ { "country": "Bangladesh", "utc": "+06:00" } ], "1-246": [ { "country": "Barbados", "utc": "−04:00" } ], "375": [ { "country": "Belarus", "utc": "+03:00" } ], "32": [ { "country": "Belgium", "utc": "+01:00" } ], "501": [ { "country": "Belize", "utc": "−06:00" } ], "229": [ { "country": "Benin", "utc": "+01:00" } ], "1-441": [ { "country": "Bermuda", "utc": "−04:00" } ], "975": [ { "country": "Bhutan", "utc": "+06:00" } ], "591": [ { "country": "Bolivia", "utc": "−04:00" } ], "599-7": [ { "country": "Bonaire", "utc": "−04:00" }, { "country": "Caribbean Netherlands", "utc": "−04:00" } ], "387": [ { "country": "Bosnia and Herzegovina", "utc": "+01:00" } ], "267": [ { "country": "Botswana", "utc": "+02:00" } ], "55": [ { "country": "Brazil", "utc": "−05:00 to –02:00" } ], "246": [ { "country": "British Indian Ocean Territory (Diego Garcia)", "utc": "+06:00" } ], "1-284": [ { "country": "British Virgin Islands", "utc": "−04:00" } ], "673": [ { "country": "Brunei Darussalam", "utc": "+08:00" } ], "359": [ { "country": "Bulgaria", "utc": "+02:00" } ], "226": [ { "country": "Burkina Faso", "utc": "+00:00" } ], "257": [ { "country": "Burundi", "utc": "+02:00" } ], "238": [ { "country": "Cape Verde", "utc": "−01:00" } ], "855": [ { "country": "Cambodia", "utc": "+07:00" } ], "237": [ { "country": "Cameroon", "utc": "+01:00" } ], "1": [ { "country": "Canada", "utc": "−08:00 to –03:30" }, { "country": "United States", "utc": "−10:00 to –05:00" } ], "599-3": [ { "country": "Caribbean Netherlands", "utc": "−04:00" }, { "country": "Sint Eustatius", "utc": "−04:00" } ], "599-4": [ { "country": "Caribbean Netherlands", "utc": "−04:00" }, { "country": "Saba", "utc": "−04:00" } ], "1-345": [ { "country": "Cayman Islands", "utc": "−05:00" } ], "236": [ { "country": "Central African Republic", "utc": "+01:00" } ], "235": [ { "country": "Chad", "utc": "+01:00" } ], "56": [ { "country": "Chile", "utc": "−06:00 to –04:00" }, { "country": "Easter Island", "utc": "−06:00" } ], "86": [ { "country": "China", "utc": "+08:00" } ], "61-89164": [ { "country": "Christmas Island", "utc": "+07:00" } ], "61-89162": [ { "country": "Cocos (Keeling) Islands", "utc": "+06:30" } ], "57": [ { "country": "Colombia", "utc": "−05:00" } ], "269": [ { "country": "Comoros", "utc": "+03:00" } ], "242": [ { "country": "Congo", "utc": "+01:00" } ], "243": [ { "country": "Congo, Democratic Republic of the", "utc": "+01:00 to +02:00" } ], "682": [ { "country": "Cook Islands", "utc": "−10:00" } ], "506": [ { "country": "Costa Rica", "utc": "−06:00" } ], "225": [ { "country": "Ivory Coast (Côte d'Ivoire)", "utc": "+00:00" } ], "385": [ { "country": "Croatia", "utc": "+01:00" } ], "53": [ { "country": "Cuba", "utc": "−05:00" } ], "599-9": [ { "country": "Curaçao", "utc": "−04:00" } ], "357": [ { "country": "Cyprus", "utc": "+02:00" } ], "420": [ { "country": "Czech Republic", "utc": "+01:00" } ], "45": [ { "country": "Denmark", "utc": "+01:00" } ], "253": [ { "country": "Djibouti", "utc": "+03:00" } ], "1-767": [ { "country": "Dominica", "utc": "−04:00" } ], "1-809": [ { "country": "Dominican Republic", "utc": "−04:00" } ], "1-829": [ { "country": "Dominican Republic", "utc": "−04:00" } ], "1-849": [ { "country": "Dominican Republic", "utc": "−04:00" } ], "593": [ { "country": "Ecuador", "utc": "−06:00 to –05:00" } ], "20": [ { "country": "Egypt", "utc": "+02:00" } ], "503": [ { "country": "El Salvador", "utc": "−06:00" } ], "881-2": [ { "country": "Ellipso (Mobile Satellite service)", "utc": "" } ], "881-3": [ { "country": "Ellipso (Mobile Satellite service)", "utc": "" } ], "882-13": [ { "country": "EMSAT (Mobile Satellite service)", "utc": "" } ], "240": [ { "country": "Equatorial Guinea", "utc": "+01:00" } ], "291": [ { "country": "Eritrea", "utc": "+03:00" } ], "372": [ { "country": "Estonia", "utc": "+02:00" } ], "268": [ { "country": "Eswatini", "utc": "+02:00" } ], "251": [ { "country": "Ethiopia", "utc": "+03:00" } ], "500": [ { "country": "Falkland Islands", "utc": "−03:00" }, { "country": "South Georgia and the South Sandwich Islands", "utc": "−02:00" } ], "298": [ { "country": "Faroe Islands", "utc": "+00:00" } ], "679": [ { "country": "Fiji", "utc": "+12:00" } ], "358": [ { "country": "Finland", "utc": "+02:00" } ], "33": [ { "country": "France", "utc": "+01:00" } ], "596": [ { "country": "French Antilles", "utc": "" }, { "country": "Martinique", "utc": "−04:00" } ], "594": [ { "country": "French Guiana", "utc": "−03:00" } ], "689": [ { "country": "French Polynesia", "utc": "−10:00 to –09:00" } ], "241": [ { "country": "Gabon", "utc": "+01:00" } ], "220": [ { "country": "Gambia", "utc": "+00:00" } ], "995": [ { "country": "Georgia", "utc": "+04:00" } ], "49": [ { "country": "Germany", "utc": "+01:00" } ], "233": [ { "country": "Ghana", "utc": "+00:00" } ], "350": [ { "country": "Gibraltar", "utc": "+01:00" } ], "881": [ { "country": "Global Mobile Satellite System (GMSS)", "utc": "" } ], "881-8": [ { "country": "Globalstar (Mobile Satellite Service)", "utc": "" } ], "881-9": [ { "country": "Globalstar (Mobile Satellite Service)", "utc": "" } ], "30": [ { "country": "Greece", "utc": "+02:00" } ], "299": [ { "country": "Greenland", "utc": "−04:00 to +01:00" } ], "1-473": [ { "country": "Grenada", "utc": "−04:00" } ], "590": [ { "country": "Guadeloupe", "utc": "−04:00" }, { "country": "Saint Barthélemy", "utc": "−04:00" }, { "country": "Saint Martin (France)", "utc": "−04:00" } ], "1-671": [ { "country": "Guam", "utc": "+10:00" } ], "502": [ { "country": "Guatemala", "utc": "−06:00" } ], "44-1481": [ { "country": "Guernsey", "utc": "+00:00" } ], "44-7781": [ { "country": "Guernsey", "utc": "+00:00" } ], "44-7839": [ { "country": "Guernsey", "utc": "+00:00" } ], "44-7911": [ { "country": "Guernsey", "utc": "+00:00" } ], "224": [ { "country": "Guinea", "utc": "+00:00" } ], "245": [ { "country": "Guinea-Bissau", "utc": "+00:00" } ], "592": [ { "country": "Guyana", "utc": "−04:00" } ], "509": [ { "country": "Haiti", "utc": "−05:00" } ], "504": [ { "country": "Honduras", "utc": "−06:00" } ], "852": [ { "country": "Hong Kong", "utc": "+08:00" } ], "36": [ { "country": "Hungary", "utc": "+01:00" } ], "354": [ { "country": "Iceland", "utc": "+00:00" } ], "881-0": [ { "country": "ICO Global (Mobile Satellite Service)", "utc": "" } ], "881-1": [ { "country": "ICO Global (Mobile Satellite Service)", "utc": "" } ], "91": [ { "country": "India", "utc": "+05:30" } ], "62": [ { "country": "Indonesia", "utc": "+07:00 to +09:00" } ], "870": [ { "country": "Inmarsat SNAC", "utc": "" } ], "800": [ { "country": "International Freephone Service (UIFN)", "utc": "" } ], "882": [ { "country": "International Networks", "utc": "" } ], "979": [ { "country": "International Premium Rate Service", "utc": "" } ], "808": [ { "country": "International Shared Cost Service (ISCS)", "utc": "" } ], "98": [ { "country": "Iran", "utc": "+03:30" } ], "964": [ { "country": "Iraq", "utc": "+03:00" } ], "353": [ { "country": "Ireland", "utc": "+00:00" } ], "881-6": [ { "country": "Iridium (Mobile Satellite service)", "utc": "" } ], "881-7": [ { "country": "Iridium (Mobile Satellite service)", "utc": "" } ], "44-1624": [ { "country": "Isle of Man", "utc": "+00:00" } ], "44-7524": [ { "country": "Isle of Man", "utc": "+00:00" } ], "44-7624": [ { "country": "Isle of Man", "utc": "+00:00" } ], "44-7924": [ { "country": "Isle of Man", "utc": "+00:00" } ], "972": [ { "country": "Israel", "utc": "+02:00" } ], "39": [ { "country": "Italy", "utc": "+01:00" } ], "1-658": [ { "country": "Jamaica", "utc": "−05:00" } ], "1-876": [ { "country": "Jamaica", "utc": "−05:00" } ], "47-79": [ { "country": "Jan Mayen", "utc": "+01:00" }, { "country": "Svalbard", "utc": "+01:00" } ], "81": [ { "country": "Japan", "utc": "+09:00" } ], "44-1534": [ { "country": "Jersey", "utc": "+00:00" } ], "962": [ { "country": "Jordan", "utc": "+02:00" } ], "7-6": [ { "country": "Kazakhstan", "utc": "+05:00" } ], "7-7": [ { "country": "Kazakhstan", "utc": "+05:00" } ], "254": [ { "country": "Kenya", "utc": "+03:00" } ], "686": [ { "country": "Kiribati", "utc": "+12:00 to +14:00" } ], "850": [ { "country": "Korea, North", "utc": "+09:00" } ], "82": [ { "country": "Korea, South", "utc": "+09:00" } ], "383": [ { "country": "Kosovo", "utc": "+01:00" } ], "965": [ { "country": "Kuwait", "utc": "+03:00" } ], "996": [ { "country": "Kyrgyzstan", "utc": "+06:00" } ], "856": [ { "country": "Laos", "utc": "+07:00" } ], "371": [ { "country": "Latvia", "utc": "+02:00" } ], "961": [ { "country": "Lebanon", "utc": "+02:00" } ], "266": [ { "country": "Lesotho", "utc": "+02:00" } ], "231": [ { "country": "Liberia", "utc": "+00:00" } ], "218": [ { "country": "Libya", "utc": "+02:00" } ], "423": [ { "country": "Liechtenstein", "utc": "+01:00" } ], "370": [ { "country": "Lithuania", "utc": "+02:00" } ], "352": [ { "country": "Luxembourg", "utc": "+01:00" } ], "853": [ { "country": "Macau", "utc": "+08:00" } ], "261": [ { "country": "Madagascar", "utc": "+03:00" } ], "265": [ { "country": "Malawi", "utc": "+02:00" } ], "60": [ { "country": "Malaysia", "utc": "+08:00" } ], "960": [ { "country": "Maldives", "utc": "+05:00" } ], "223": [ { "country": "Mali", "utc": "+00:00" } ], "356": [ { "country": "Malta", "utc": "+01:00" } ], "692": [ { "country": "Marshall Islands", "utc": "+12:00" } ], "222": [ { "country": "Mauritania", "utc": "+00:00" } ], "230": [ { "country": "Mauritius", "utc": "+04:00" } ], "262-269": [ { "country": "Mayotte", "utc": "+03:00" } ], "262-639": [ { "country": "Mayotte", "utc": "+03:00" } ], "52": [ { "country": "Mexico", "utc": "−08:00 to –05:00" } ], "691": [ { "country": "Micronesia, Federated States of", "utc": "+10:00 to +11:00" } ], "373": [ { "country": "Moldova", "utc": "+02:00" } ], "377": [ { "country": "Monaco", "utc": "+01:00" } ], "976": [ { "country": "Mongolia", "utc": "+07:00 to +08:00" } ], "382": [ { "country": "Montenegro", "utc": "+01:00" } ], "1-664": [ { "country": "Montserrat", "utc": "−04:00" } ], "212": [ { "country": "Morocco", "utc": "+01:00" } ], "258": [ { "country": "Mozambique", "utc": "+02:00" } ], "95": [ { "country": "Myanmar", "utc": "+06:30" } ], "264": [ { "country": "Namibia", "utc": "+02:00" } ], "674": [ { "country": "Nauru", "utc": "+12:00" } ], "977": [ { "country": "Nepal", "utc": "+05:45" } ], "31": [ { "country": "Netherlands", "utc": "+01:00" } ], "1-869": [ { "country": "Nevis", "utc": "−04:00" }, { "country": "Saint Kitts and Nevis", "utc": "−04:00" } ], "687": [ { "country": "New Caledonia", "utc": "+11:00" } ], "64": [ { "country": "New Zealand", "utc": "+12:00" }, { "country": "Pitcairn Islands", "utc": "−08:00" } ], "505": [ { "country": "Nicaragua", "utc": "−06:00" } ], "227": [ { "country": "Niger", "utc": "+01:00" } ], "234": [ { "country": "Nigeria", "utc": "+01:00" } ], "683": [ { "country": "Niue", "utc": "−11:00" } ], "672-3": [ { "country": "Norfolk Island", "utc": "+11:00" } ], "389": [ { "country": "North Macedonia", "utc": "+01:00" } ], "90-392": [ { "country": "Northern Cyprus", "utc": "+02:00" } ], "44-28": [ { "country": "Northern Ireland", "utc": "+00:00" } ], "1-670": [ { "country": "Northern Mariana Islands", "utc": "+10:00" } ], "47": [ { "country": "Norway", "utc": "+01:00" } ], "968": [ { "country": "Oman", "utc": "+04:00" } ], "92": [ { "country": "Pakistan", "utc": "+05:00" } ], "680": [ { "country": "Palau", "utc": "+09:00" } ], "970": [ { "country": "Palestine", "utc": "+02:00" } ], "507": [ { "country": "Panama", "utc": "−05:00" } ], "675": [ { "country": "Papua New Guinea", "utc": "+10:00 to +11:00" } ], "595": [ { "country": "Paraguay", "utc": "−04:00" } ], "51": [ { "country": "Peru", "utc": "−05:00" } ], "63": [ { "country": "Philippines", "utc": "+08:00" } ], "48": [ { "country": "Poland", "utc": "+01:00" } ], "351": [ { "country": "Portugal", "utc": "+00:00" } ], "1-787": [ { "country": "Puerto Rico", "utc": "−04:00" } ], "1-939": [ { "country": "Puerto Rico", "utc": "−04:00" } ], "974": [ { "country": "Qatar", "utc": "+03:00" } ], "262": [ { "country": "Réunion", "utc": "+04:00" } ], "40": [ { "country": "Romania", "utc": "+02:00" } ], "7": [ { "country": "Russia", "utc": "+02:00 to +12:00" } ], "250": [ { "country": "Rwanda", "utc": "+02:00" } ], "290": [ { "country": "Saint Helena", "utc": "+00:00" } ], "1-758": [ { "country": "Saint Lucia", "utc": "−04:00" } ], "508": [ { "country": "Saint Pierre and Miquelon", "utc": "−03:00" } ], "1-784": [ { "country": "Saint Vincent and the Grenadines", "utc": "−04:00" } ], "685": [ { "country": "Samoa", "utc": "+13:00" } ], "378": [ { "country": "San Marino", "utc": "+01:00" } ], "239": [ { "country": "São Tomé and Príncipe", "utc": "+00:00" } ], "966": [ { "country": "Saudi Arabia", "utc": "+03:00" } ], "221": [ { "country": "Senegal", "utc": "+00:00" } ], "381": [ { "country": "Serbia", "utc": "+01:00" } ], "248": [ { "country": "Seychelles", "utc": "+04:00" } ], "232": [ { "country": "Sierra Leone", "utc": "+00:00" } ], "65": [ { "country": "Singapore", "utc": "+08:00" } ], "1-721": [ { "country": "Sint Maarten (Netherlands)", "utc": "−04:00" } ], "421": [ { "country": "Slovakia", "utc": "+01:00" } ], "386": [ { "country": "Slovenia", "utc": "+01:00" } ], "677": [ { "country": "Solomon Islands", "utc": "+11:00" } ], "252": [ { "country": "Somalia", "utc": "+03:00" } ], "27": [ { "country": "South Africa", "utc": "+02:00" } ], "7-850": [ { "country": "South Ossetia", "utc": "+03:00" } ], "7-929": [ { "country": "South Ossetia", "utc": "+03:00" } ], "211": [ { "country": "South Sudan", "utc": "+02:00" } ], "34": [ { "country": "Spain", "utc": "+01:00" } ], "94": [ { "country": "Sri Lanka", "utc": "+05:30" } ], "249": [ { "country": "Sudan", "utc": "+02:00" } ], "597": [ { "country": "Suriname", "utc": "−03:00" } ], "46": [ { "country": "Sweden", "utc": "+01:00" } ], "41": [ { "country": "Switzerland", "utc": "+01:00" } ], "963": [ { "country": "Syria", "utc": "+02:00" } ], "886": [ { "country": "Taiwan", "utc": "+08:00" } ], "992": [ { "country": "Tajikistan", "utc": "+05:00" } ], "255": [ { "country": "Tanzania", "utc": "+03:00" } ], "66": [ { "country": "Thailand", "utc": "+07:00" } ], "882-16": [ { "country": "Thuraya (Mobile Satellite service)", "utc": "" } ], "670": [ { "country": "East Timor (Timor-Leste)", "utc": "+09:00" } ], "228": [ { "country": "Togo", "utc": "+00:00" } ], "690": [ { "country": "Tokelau", "utc": "+13:00" } ], "676": [ { "country": "Tonga", "utc": "+13:00" } ], "373-2": [ { "country": "Transnistria", "utc": "+02:00" } ], "373-5": [ { "country": "Transnistria", "utc": "+02:00" } ], "1-868": [ { "country": "Trinidad and Tobago", "utc": "−04:00" } ], "290-8": [ { "country": "Tristan da Cunha", "utc": "+00:00" } ], "216": [ { "country": "Tunisia", "utc": "+01:00" } ], "90": [ { "country": "Turkey", "utc": "+03:00" } ], "993": [ { "country": "Turkmenistan", "utc": "+05:00" } ], "1-649": [ { "country": "Turks and Caicos Islands", "utc": "−05:00" } ], "688": [ { "country": "Tuvalu", "utc": "+12:00" } ], "256": [ { "country": "Uganda", "utc": "+03:00" } ], "380": [ { "country": "Ukraine", "utc": "+02:00" } ], "971": [ { "country": "United Arab Emirates", "utc": "+04:00" } ], "44": [ { "country": "United Kingdom", "utc": "+00:00" } ], "598": [ { "country": "Uruguay", "utc": "−03:00" } ], "1-340": [ { "country": "US Virgin Islands", "utc": "−04:00" } ], "998": [ { "country": "Uzbekistan", "utc": "+05:00" } ], "678": [ { "country": "Vanuatu", "utc": "+11:00" } ], "39-06698": [ { "country": "Vatican City State (Holy See)", "utc": "+01:00" } ], "58": [ { "country": "Venezuela", "utc": "−04:00" } ], "84": [ { "country": "Vietnam", "utc": "+07:00" } ], "1-808": [ { "country": "Wake Island, USA", "utc": "+12:00" } ], "681": [ { "country": "Wallis and Futuna", "utc": "+12:00" } ], "967": [ { "country": "Yemen", "utc": "+03:00" } ], "260": [ { "country": "Zambia", "utc": "+02:00" } ], "255-24": [ { "country": "Zanzibar", "utc": "+03:00" } ], "263": [ { "country": "Zimbabwe", "utc": "+02:00" } ] } -
country_names.json 21.6 KB
{ "_meta": { "source": ["Manually curated Chinese/English country and territory names for country lookups."], "fetched": "2026-09-14", "count": 300, "note": "en2zh keys match the English names in the lookup tables. aliases maps short names, traditional Chinese forms, historical names and abbreviations to those keys. clues.py also handles parenthesized text and substring matching." }, "en2zh": { "Abkhazia": "阿布哈兹", "Afghanistan": "阿富汗", "Albania": "阿尔巴尼亚", "Algeria": "阿尔及利亚", "American Samoa": "美属萨摩亚", "Andorra": "安道尔", "Angola": "安哥拉", "Anguilla": "安圭拉", "Antigua and Barbuda": "安提瓜和巴布达", "Argentina": "阿根廷", "Armenia": "亚美尼亚", "Aruba": "阿鲁巴", "Ascension": "阿森松岛", "Australia": "澳大利亚", "Austria": "奥地利", "Azerbaijan": "阿塞拜疆", "Bahamas": "巴哈马", "Bahrain": "巴林", "Bangladesh": "孟加拉国", "Barbados": "巴巴多斯", "Belarus": "白俄罗斯", "Belgium": "比利时", "Belize": "伯利兹", "Benin": "贝宁", "Bermuda": "百慕大", "Bhutan": "不丹", "Bolivia": "玻利维亚", "Bonaire": "博奈尔", "Bosnia and Herzegovina": "波斯尼亚和黑塞哥维那", "Botswana": "博茨瓦纳", "Brazil": "巴西", "British Indian Ocean Territory": "英属印度洋领地", "British Indian Ocean Territory (Diego Garcia)": "英属印度洋领地(迪戈加西亚)", "British Virgin Islands": "英属维尔京群岛", "Brunei": "文莱", "Brunei Darussalam": "文莱", "Bulgaria": "保加利亚", "Burkina Faso": "布基纳法索", "Burundi": "布隆迪", "Cambodia": "柬埔寨", "Cameroon": "喀麦隆", "Canada": "加拿大", "Cape Verde": "佛得角", "Caribbean Netherlands": "荷兰加勒比区", "Cayman Islands": "开曼群岛", "Central African Republic": "中非共和国", "Chad": "乍得", "Chile": "智利", "China": "中国", "Christmas Island": "圣诞岛", "Cocos (Keeling) Islands": "科科斯(基林)群岛", "Colombia": "哥伦比亚", "Comoros": "科摩罗", "Congo": "刚果(布)", "Congo, Democratic Republic of the": "刚果(金)", "DR Congo": "刚果(金)", "Cook Islands": "库克群岛", "Costa Rica": "哥斯达黎加", "Croatia": "克罗地亚", "Cuba": "古巴", "Curaçao": "库拉索", "Cyprus": "塞浦路斯", "Czech Republic": "捷克", "Czechia": "捷克", "Denmark": "丹麦", "Djibouti": "吉布提", "Dominica": "多米尼克", "Dominican Republic": "多米尼加", "East Timor": "东帝汶", "East Timor (Timor-Leste)": "东帝汶", "Timor-Leste": "东帝汶", "Easter Island": "复活节岛", "Ecuador": "厄瓜多尔", "Egypt": "埃及", "El Salvador": "萨尔瓦多", "Equatorial Guinea": "赤道几内亚", "Eritrea": "厄立特里亚", "Estonia": "爱沙尼亚", "Eswatini": "斯威士兰", "Ethiopia": "埃塞俄比亚", "Falkland Islands": "福克兰群岛(马尔维纳斯群岛)", "Faroe Islands": "法罗群岛", "Federated States of Micronesia": "密克罗尼西亚联邦", "Micronesia, Federated States of": "密克罗尼西亚联邦", "Fiji": "斐济", "Finland": "芬兰", "France": "法国", "French Antilles": "法属安的列斯", "French Guiana": "法属圭亚那", "French Polynesia": "法属波利尼西亚", "Gabon": "加蓬", "Gambia": "冈比亚", "Georgia": "格鲁吉亚", "Germany": "德国", "Ghana": "加纳", "Gibraltar": "直布罗陀", "Greece": "希腊", "Greenland": "格陵兰", "Grenada": "格林纳达", "Guadeloupe": "瓜德罗普", "Guam": "关岛", "Guatemala": "危地马拉", "Guernsey": "根西", "Guinea": "几内亚", "Guinea-Bissau": "几内亚比绍", "Guyana": "圭亚那", "Haiti": "海地", "Honduras": "洪都拉斯", "Hong Kong": "香港", "Hungary": "匈牙利", "Iceland": "冰岛", "India": "印度", "Indonesia": "印度尼西亚", "Iran": "伊朗", "Iraq": "伊拉克", "Ireland": "爱尔兰", "Isle of Man": "马恩岛", "Israel": "以色列", "Italy": "意大利", "Ivory Coast": "科特迪瓦", "Ivory Coast (Côte d'Ivoire)": "科特迪瓦", "Jamaica": "牙买加", "Jan Mayen": "扬马延岛", "Japan": "日本", "Jersey": "泽西", "Jordan": "约旦", "Kazakhstan": "哈萨克斯坦", "Kenya": "肯尼亚", "Kiribati": "基里巴斯", "Korea, North": "朝鲜", "North Korea": "朝鲜", "Korea, South": "韩国", "South Korea": "韩国", "Kosovo": "科索沃", "Kuwait": "科威特", "Kyrgyzstan": "吉尔吉斯斯坦", "Laos": "老挝", "Latvia": "拉脱维亚", "Lebanon": "黎巴嫩", "Lesotho": "莱索托", "Liberia": "利比里亚", "Libya": "利比亚", "Liechtenstein": "列支敦士登", "Lithuania": "立陶宛", "Luxembourg": "卢森堡", "Macau": "澳门", "Macao": "澳门", "Madagascar": "马达加斯加", "Malawi": "马拉维", "Malaysia": "马来西亚", "Maldives": "马尔代夫", "Mali": "马里", "Malta": "马耳他", "Marshall Islands": "马绍尔群岛", "Martinique": "马提尼克", "Mauritania": "毛里塔尼亚", "Mauritius": "毛里求斯", "Mayotte": "马约特", "Mexico": "墨西哥", "Moldova": "摩尔多瓦", "Monaco": "摩纳哥", "Mongolia": "蒙古", "Montenegro": "黑山", "Montserrat": "蒙特塞拉特", "Morocco": "摩洛哥", "Mozambique": "莫桑比克", "Myanmar": "缅甸", "Namibia": "纳米比亚", "Nauru": "瑙鲁", "Nepal": "尼泊尔", "Netherlands": "荷兰", "Nevis": "尼维斯", "New Caledonia": "新喀里多尼亚", "New Zealand": "新西兰", "Nicaragua": "尼加拉瓜", "Niger": "尼日尔", "Nigeria": "尼日利亚", "Niue": "纽埃", "Norfolk Island": "诺福克岛", "North Macedonia": "北马其顿", "Northern Cyprus": "北塞浦路斯", "Northern Ireland": "北爱尔兰", "Northern Mariana Islands": "北马里亚纳群岛", "Norway": "挪威", "Oman": "阿曼", "Pakistan": "巴基斯坦", "Palau": "帕劳", "Palestine": "巴勒斯坦", "Panama": "巴拿马", "Papua New Guinea": "巴布亚新几内亚", "Paraguay": "巴拉圭", "Peru": "秘鲁", "Philippines": "菲律宾", "Pitcairn Islands": "皮特凯恩群岛", "Poland": "波兰", "Portugal": "葡萄牙", "Puerto Rico": "波多黎各", "Qatar": "卡塔尔", "Romania": "罗马尼亚", "Russia": "俄罗斯", "Rwanda": "卢旺达", "Réunion": "留尼汪", "Saba": "萨巴", "Saint Barthélemy": "圣巴泰勒米", "Saint Helena": "圣赫勒拿", "Saint Helena, Ascension and Tristan da Cunha": "圣赫勒拿、阿森松和特里斯坦-达库尼亚", "Saint Kitts and Nevis": "圣基茨和尼维斯", "Saint Lucia": "圣卢西亚", "Saint Martin": "法属圣马丁", "Saint Martin (France)": "法属圣马丁", "Saint Pierre and Miquelon": "圣皮埃尔和密克隆", "Saint Vincent and the Grenadines": "圣文森特和格林纳丁斯", "Samoa": "萨摩亚", "San Marino": "圣马力诺", "Saudi Arabia": "沙特阿拉伯", "Senegal": "塞内加尔", "Serbia": "塞尔维亚", "Seychelles": "塞舌尔", "Sierra Leone": "塞拉利昂", "Singapore": "新加坡", "Sint Eustatius": "圣尤斯特歇斯", "Sint Maarten": "荷属圣马丁", "Sint Maarten (Netherlands)": "荷属圣马丁", "Slovakia": "斯洛伐克", "Slovenia": "斯洛文尼亚", "Solomon Islands": "所罗门群岛", "Somalia": "索马里", "South Africa": "南非", "South Georgia and the South Sandwich Islands": "南乔治亚和南桑威奇群岛", "South Ossetia": "南奥塞梯", "South Sudan": "南苏丹", "Spain": "西班牙", "Sri Lanka": "斯里兰卡", "Sudan": "苏丹", "Suriname": "苏里南", "Svalbard": "斯瓦尔巴", "Sweden": "瑞典", "Switzerland": "瑞士", "Syria": "叙利亚", "São Tomé and Príncipe": "圣多美和普林西比", "Taiwan": "台湾", "Tajikistan": "塔吉克斯坦", "Tanzania": "坦桑尼亚", "Thailand": "泰国", "Togo": "多哥", "Tokelau": "托克劳", "Tonga": "汤加", "Transnistria": "德涅斯特河沿岸", "Trinidad and Tobago": "特立尼达和多巴哥", "Tristan da Cunha": "特里斯坦-达库尼亚", "Tunisia": "突尼斯", "Turkey": "土耳其", "Türkiye": "土耳其", "Turkmenistan": "土库曼斯坦", "Turks and Caicos Islands": "特克斯和凯科斯群岛", "Tuvalu": "图瓦卢", "US Virgin Islands": "美属维尔京群岛", "U.S. Virgin Islands": "美属维尔京群岛", "Uganda": "乌干达", "Ukraine": "乌克兰", "United Arab Emirates": "阿联酋", "United Kingdom": "英国", "United Kingdom and overseas territories": "英国及海外领地", "United States": "美国", "Uruguay": "乌拉圭", "Uzbekistan": "乌兹别克斯坦", "Vanuatu": "瓦努阿图", "Vatican City": "梵蒂冈", "Vatican City State (Holy See)": "梵蒂冈", "Venezuela": "委内瑞拉", "Vietnam": "越南", "Wake Island": "威克岛", "Wake Island, USA": "威克岛", "Wallis and Futuna": "瓦利斯和富图纳", "Western Sahara": "西撒哈拉", "Yemen": "也门", "Zambia": "赞比亚", "Zanzibar": "桑给巴尔", "Zimbabwe": "津巴布韦", "Åland": "奥兰群岛", "Akrotiri and Dhekelia": "亚克罗提利与德凯利亚", "Ashmore and Cartier Islands": "阿什莫尔和卡捷群岛", "Australian Antarctic Territory": "澳大利亚南极领地", "Baker Island": "贝克岛", "Bajo Nuevo Bank": "巴霍努埃沃浅滩", "Balearic Islands": "巴利阿里群岛", "Bouvet Island": "布韦岛", "British Antarctic Territory": "英属南极领地", "Canary Islands": "加那利群岛", "Ceuta": "休达", "Clipperton Island": "克利珀顿岛", "Coral Sea Islands": "珊瑚海群岛", "Crozet Islands": "克罗泽群岛", "Adélie Land": "阿黛利地", "French Southern and Antarctic Lands": "法属南部和南极领地", "Heard Island and McDonald Islands": "赫德岛和麦克唐纳群岛", "Howland Island": "豪兰岛", "Jarvis Island": "贾维斯岛", "Johnston Atoll": "约翰斯顿环礁", "Kerguelen Islands": "凯尔盖朗群岛", "Kingman Reef": "金曼礁", "Madeira": "马德拉", "Azores": "亚速尔群岛", "Melilla": "梅利利亚", "Midway Atoll": "中途岛", "Navassa Island": "纳瓦萨岛", "Palmyra Atoll": "帕迈拉环礁", "Peter I Island": "彼得一世岛", "Queen Maud Land": "毛德皇后地", "Ross Dependency": "罗斯属地", "Scattered Islands in the Indian Ocean": "印度洋诸岛", "Îles Éparses": "印度洋诸岛", "Serranilla Bank": "塞拉尼拉浅滩", "Île Amsterdam": "阿姆斯特丹岛", "Île Saint-Paul": "圣保罗岛", "European Union": "欧盟" }, "aliases": { "美利坚合众国": "United States", "USA": "United States", "US": "United States", "U.S.": "United States", "America": "United States", "美利坚": "United States", "英國": "United Kingdom", "UK": "United Kingdom", "Britain": "United Kingdom", "Great Britain": "United Kingdom", "England": "United Kingdom", "英格兰": "United Kingdom", "大不列颠": "United Kingdom", "中华人民共和国": "China", "中國": "China", "PRC": "China", "大陆": "China", "中国大陆": "China", "Mainland China": "China", "德國": "Germany", "Deutschland": "Germany", "法國": "France", "日本国": "Japan", "韓國": "South Korea", "南韩": "South Korea", "大韩民国": "South Korea", "Korea": "South Korea", "ROK": "South Korea", "北韩": "North Korea", "北朝鲜": "North Korea", "DPRK": "North Korea", "朝鮮": "North Korea", "俄國": "Russia", "俄罗斯联邦": "Russia", "Russian Federation": "Russia", "苏联": "Russia", "USSR": "Russia", "澳洲": "Australia", "澳大利亞": "Australia", "纽西兰": "New Zealand", "紐西蘭": "New Zealand", "NZ": "New Zealand", "荷蘭": "Netherlands", "Holland": "Netherlands", "尼德兰": "Netherlands", "The Netherlands": "Netherlands", "義大利": "Italy", "西班牙王国": "Spain", "葡国": "Portugal", "瑞典王国": "Sweden", "丹麥": "Denmark", "芬蘭": "Finland", "挪威王国": "Norway", "阿聯酋": "United Arab Emirates", "阿拉伯联合酋长国": "United Arab Emirates", "UAE": "United Arab Emirates", "沙特": "Saudi Arabia", "沙烏地阿拉伯": "Saudi Arabia", "KSA": "Saudi Arabia", "土耳其共和国": "Turkey", "Turkiye": "Turkey", "捷克共和国": "Czech Republic", "Czechia": "Czech Republic", "香港特别行政区": "Hong Kong", "香港特區": "Hong Kong", "HK": "Hong Kong", "HKSAR": "Hong Kong", "澳門": "Macau", "澳门特别行政区": "Macau", "Macao": "Macau", "MO": "Macau", "台灣": "Taiwan", "中华民国": "Taiwan", "中華民國": "Taiwan", "Republic of China": "Taiwan", "ROC": "Taiwan", "Chinese Taipei": "Taiwan", "刚果民主共和国": "DR Congo", "民主刚果": "DR Congo", "扎伊尔": "DR Congo", "DRC": "DR Congo", "Congo-Kinshasa": "DR Congo", "Democratic Republic of the Congo": "DR Congo", "刚果共和国": "Congo", "Congo-Brazzaville": "Congo", "Republic of the Congo": "Congo", "象牙海岸": "Ivory Coast", "Côte d'Ivoire": "Ivory Coast", "Cote d'Ivoire": "Ivory Coast", "科特迪瓦共和国": "Ivory Coast", "缅甸联邦": "Myanmar", "Burma": "Myanmar", "斯威士兰王国": "Eswatini", "Swaziland": "Eswatini", "史瓦帝尼": "Eswatini", "马其顿": "North Macedonia", "Macedonia": "North Macedonia", "梵蒂冈城国": "Vatican City", "Holy See": "Vatican City", "教廷": "Vatican City", "东帝汶民主共和国": "East Timor", "帝汶": "East Timor", "佛得角共和国": "Cape Verde", "Cabo Verde": "Cape Verde", "密克罗尼西亚": "Federated States of Micronesia", "Micronesia": "Federated States of Micronesia", "FSM": "Federated States of Micronesia", "阿拉伯埃及共和国": "Egypt", "伊朗伊斯兰共和国": "Iran", "波斯": "Iran", "叙利亚阿拉伯共和国": "Syria", "老挝人民民主共和国": "Laos", "寮国": "Laos", "Lao": "Laos", "越南社会主义共和国": "Vietnam", "Viet Nam": "Vietnam", "泰王国": "Thailand", "暹罗": "Thailand", "马来亚": "Malaysia", "大马": "Malaysia", "星洲": "Singapore", "新加坡共和国": "Singapore", "獅城": "Singapore", "印尼": "Indonesia", "菲国": "Philippines", "菲律賓": "Philippines", "柬国": "Cambodia", "高棉": "Cambodia", "汶莱": "Brunei", "文莱达鲁萨兰国": "Brunei", "印度共和国": "India", "巴国": "Pakistan", "孟加拉": "Bangladesh", "锡兰": "Sri Lanka", "Ceylon": "Sri Lanka", "尼泊尔联邦": "Nepal", "蒙古国": "Mongolia", "外蒙古": "Mongolia", "哈萨克": "Kazakhstan", "乌兹别克": "Uzbekistan", "吉尔吉斯": "Kyrgyzstan", "塔吉克": "Tajikistan", "土库曼": "Turkmenistan", "格鲁吉亚共和国": "Georgia", "乔治亚": "Georgia", "亚美尼亚共和国": "Armenia", "阿塞拜疆共和国": "Azerbaijan", "亞塞拜然": "Azerbaijan", "阿富汗伊斯兰": "Afghanistan", "伊拉克共和国": "Iraq", "科威特国": "Kuwait", "卡塔尔国": "Qatar", "卡達": "Qatar", "巴林王国": "Bahrain", "阿曼苏丹国": "Oman", "也门共和国": "Yemen", "葉門": "Yemen", "以色列国": "Israel", "巴勒斯坦国": "Palestine", "约旦哈希姆王国": "Jordan", "黎巴嫩共和国": "Lebanon", "塞浦路斯共和国": "Cyprus", "賽普勒斯": "Cyprus", "阿尔及利亚民主人民共和国": "Algeria", "摩洛哥王国": "Morocco", "突尼斯共和国": "Tunisia", "利比亚国": "Libya", "苏丹共和国": "Sudan", "埃塞": "Ethiopia", "衣索比亞": "Ethiopia", "肯尼亚共和国": "Kenya", "肯亞": "Kenya", "坦尚尼亞": "Tanzania", "乌干达共和国": "Uganda", "卢旺达共和国": "Rwanda", "盧安達": "Rwanda", "南非共和国": "South Africa", "RSA": "South Africa", "尼日利亚联邦共和国": "Nigeria", "奈及利亞": "Nigeria", "加纳共和国": "Ghana", "迦納": "Ghana", "塞内加尔共和国": "Senegal", "喀麦隆共和国": "Cameroon", "安哥拉共和国": "Angola", "莫桑比克共和国": "Mozambique", "莫三比克": "Mozambique", "津巴布韦共和国": "Zimbabwe", "辛巴威": "Zimbabwe", "赞比亚共和国": "Zambia", "尚比亞": "Zambia", "纳米比亚共和国": "Namibia", "马达加斯加共和国": "Madagascar", "毛里求斯共和国": "Mauritius", "模里西斯": "Mauritius", "塞舌尔共和国": "Seychelles", "塞席爾": "Seychelles", "墨西哥合众国": "Mexico", "巴西联邦共和国": "Brazil", "阿根廷共和国": "Argentina", "智利共和国": "Chile", "秘鲁共和国": "Peru", "祕魯": "Peru", "哥伦比亚共和国": "Colombia", "哥倫比亞": "Colombia", "委内瑞拉玻利瓦尔共和国": "Venezuela", "厄瓜多尔共和国": "Ecuador", "厄瓜多": "Ecuador", "玻利维亚多民族国": "Bolivia", "巴拉圭共和国": "Paraguay", "乌拉圭东岸共和国": "Uruguay", "圭亚那合作共和国": "Guyana", "蘇利南": "Suriname", "古巴共和国": "Cuba", "牙买加": "Jamaica", "多米尼加共和国": "Dominican Republic", "海地共和国": "Haiti", "波多黎各自由邦": "Puerto Rico", "危地马拉共和国": "Guatemala", "瓜地馬拉": "Guatemala", "洪都拉斯共和国": "Honduras", "宏都拉斯": "Honduras", "萨尔瓦多共和国": "El Salvador", "尼加拉瓜共和国": "Nicaragua", "哥斯达黎加共和国": "Costa Rica", "哥斯大黎加": "Costa Rica", "巴拿马共和国": "Panama", "貝里斯": "Belize", "加拿大联邦": "Canada", "格陵兰岛": "Greenland", "格陵蘭": "Greenland", "法属圭亚那": "French Guiana", "法屬圭亞那": "French Guiana", "圭亚那(法属)": "French Guiana", "Guyane": "French Guiana", "法属波利尼西亚": "French Polynesia", "大溪地": "French Polynesia", "塔希提": "French Polynesia", "Tahiti": "French Polynesia", "新喀里多尼亞": "New Caledonia", "Nouvelle-Calédonie": "New Caledonia", "留尼旺": "Réunion", "Reunion": "Réunion", "马约特岛": "Mayotte", "瓜德罗普岛": "Guadeloupe", "马提尼克岛": "Martinique", "圣皮埃尔和密克隆群岛": "Saint Pierre and Miquelon", "瓦利斯和富图纳群岛": "Wallis and Futuna", "圣巴泰勒米岛": "Saint Barthélemy", "圣巴特": "Saint Barthélemy", "St. Barts": "Saint Barthélemy", "法属圣马丁岛": "Saint Martin", "荷属圣马丁岛": "Sint Maarten", "圣马丁": "Saint Martin", "福克兰群岛": "Falkland Islands", "马尔维纳斯群岛": "Falkland Islands", "Malvinas": "Falkland Islands", "福克蘭群島": "Falkland Islands", "直布罗陀": "Gibraltar", "百慕大群岛": "Bermuda", "百慕達": "Bermuda", "开曼": "Cayman Islands", "开曼群岛": "Cayman Islands", "英属维京群岛": "British Virgin Islands", "BVI": "British Virgin Islands", "美属维京群岛": "US Virgin Islands", "美屬維京群島": "US Virgin Islands", "USVI": "US Virgin Islands", "关岛": "Guam", "關島": "Guam", "北马里亚纳": "Northern Mariana Islands", "塞班": "Northern Mariana Islands", "Saipan": "Northern Mariana Islands", "库克群岛": "Cook Islands", "紐埃": "Niue", "托克劳群岛": "Tokelau", "诺福克岛": "Norfolk Island", "圣诞岛": "Christmas Island", "科科斯群岛": "Cocos (Keeling) Islands", "法罗群岛": "Faroe Islands", "法羅群島": "Faroe Islands", "奥兰": "Åland", "Aland": "Åland", "斯瓦尔巴群岛": "Svalbard", "斯瓦尔巴和扬马延": "Svalbard", "泽西岛": "Jersey", "根西岛": "Guernsey", "马恩岛": "Isle of Man", "曼岛": "Isle of Man", "阿鲁巴岛": "Aruba", "库拉索岛": "Curaçao", "Curacao": "Curaçao", "博内尔": "Bonaire", "荷属加勒比": "Caribbean Netherlands", "BES": "Caribbean Netherlands", "圣赫勒拿岛": "Saint Helena", "St Helena": "Saint Helena", "阿森松": "Ascension", "皮特凯恩": "Pitcairn Islands", "特克斯和凯科斯": "Turks and Caicos Islands", "蒙塞拉特": "Montserrat", "安圭拉岛": "Anguilla", "加那利": "Canary Islands", "加纳利群岛": "Canary Islands", "亚速尔": "Azores", "马德拉群岛": "Madeira", "休达和梅利利亚": "Ceuta", "西撒": "Western Sahara", "科索沃共和国": "Kosovo", "波黑": "Bosnia and Herzegovina", "波士尼亞": "Bosnia and Herzegovina", "黑山共和国": "Montenegro", "蒙特内哥罗": "Montenegro", "白俄": "Belarus", "白羅斯": "Belarus", "摩尔多瓦共和国": "Moldova", "摩爾多瓦": "Moldova", "乌克兰": "Ukraine", "烏克蘭": "Ukraine", "瑞士联邦": "Switzerland", "奥地利共和国": "Austria", "奧地利": "Austria", "比利时王国": "Belgium", "比利時": "Belgium", "卢森堡大公国": "Luxembourg", "盧森堡": "Luxembourg", "爱尔兰共和国": "Ireland", "愛爾蘭": "Ireland", "冰岛共和国": "Iceland", "希腊共和国": "Greece", "希臘": "Greece", "波兰共和国": "Poland", "波蘭": "Poland", "匈牙利": "Hungary", "斯洛伐克共和国": "Slovakia", "斯洛文尼亚共和国": "Slovenia", "斯洛維尼亞": "Slovenia", "克罗地亚共和国": "Croatia", "克羅埃西亞": "Croatia", "塞尔维亚共和国": "Serbia", "塞爾維亞": "Serbia", "罗马尼亚": "Romania", "羅馬尼亞": "Romania", "保加利亚共和国": "Bulgaria", "阿尔巴尼亚共和国": "Albania", "爱沙尼亚共和国": "Estonia", "拉脱维亚共和国": "Latvia", "立陶宛共和国": "Lithuania", "马耳他共和国": "Malta", "馬爾他": "Malta", "摩纳哥公国": "Monaco", "圣马力诺共和国": "San Marino", "安道尔公国": "Andorra", "列支敦士登公国": "Liechtenstein", "斐济共和国": "Fiji", "斐濟": "Fiji", "巴新": "Papua New Guinea", "巴布亚新几内亚独立国": "Papua New Guinea", "所罗门": "Solomon Islands", "索羅門群島": "Solomon Islands", "瓦努阿图共和国": "Vanuatu", "萬那杜": "Vanuatu", "萨摩亚独立国": "Samoa", "西萨摩亚": "Samoa", "薩摩亞": "Samoa", "汤加王国": "Tonga", "東加": "Tonga", "图瓦卢": "Tuvalu", "吐瓦魯": "Tuvalu", "基里巴斯共和国": "Kiribati", "吉里巴斯": "Kiribati", "瑙鲁共和国": "Nauru", "諾魯": "Nauru", "帕劳共和国": "Palau", "帛琉": "Palau", "马绍尔": "Marshall Islands", "馬紹爾群島": "Marshall Islands", "斯里兰卡民主社会主义共和国": "Sri Lanka", "马尔代夫共和国": "Maldives", "馬爾地夫": "Maldives", "不丹王国": "Bhutan", "汶莱达鲁萨兰": "Brunei Darussalam", "欧洲联盟": "European Union", "EU": "European Union", "歐盟": "European Union" } } -
driving_side.json 32.2 KB
{ "_meta": { "source": [ "https://en.wikipedia.org/wiki/Left-_and_right-hand_traffic" ], "fetched": "2026-10-08", "license": "Derived from Wikipedia, CC BY-SA 4.0", "count": 241 }, "Afghanistan": { "side": "right", "switched": "1955", "note": "Kabul adopted RHT in 1955." }, "Albania": { "side": "right", "switched": "1926", "note": "" }, "Algeria": { "side": "right", "switched": "1900", "note": "Part of France until 1962." }, "Andorra": { "side": "right", "switched": "1724", "note": "Landlocked between France and Spain." }, "Angola": { "side": "right", "switched": "1928", "note": "Portuguese colony until 1975." }, "Antigua and Barbuda": { "side": "left", "switched": "", "note": "These Caribbean islands were a British colony until 1958." }, "Argentina": { "side": "right", "switched": "10 June 1945", "note": "The anniversary on 10 June is still observed each year as Día de la Seguridad Vial (road safety day)." }, "Armenia": { "side": "right", "switched": "1923", "note": "" }, "Australia": { "side": "left", "switched": "", "note": "British colonies before 1901. Includes Australian external territories." }, "Austria": { "side": "right", "switched": "1921–38", "note": "Originally LHT, like most of Austria-Hungary, but switched sides after the annexation of Austria by Nazi Germany." }, "Azerbaijan": { "side": "right", "switched": "1966", "note": "" }, "Bahamas": { "side": "left", "switched": "", "note": "British colony before 1973. Caribbean island. Most passenger vehicles are LHD due to them being imported from the United States." }, "Bahrain": { "side": "right", "switched": "November 1967", "note": "Former British protectorate. Switched to the same side as its neighbours. An island nation, linked by road to the Arabian mainland since 1986." }, "Bangladesh": { "side": "left", "switched": "", "note": "Part of Pakistan before 1971, which was part of British India before 1947." }, "Barbados": { "side": "left", "switched": "", "note": "This Atlantic island state was a British colony before 1966." }, "Belarus": { "side": "right", "switched": "1966", "note": "" }, "Belgium": { "side": "right", "switched": "1899", "note": "" }, "Belize": { "side": "right", "switched": "1961", "note": "British colony before 1981. Switched to same side as neighbours." }, "Benin": { "side": "right", "switched": "2022", "note": "Part of French West Africa before 1960." }, "Bhutan": { "side": "left", "switched": "", "note": "Under British protection before 1949." }, "Bolivia": { "side": "right", "switched": "", "note": "Yungas Road is LHT." }, "Bosnia and Herzegovina": { "side": "right", "switched": "1918", "note": "Switched sides after the collapse of Austria-Hungary." }, "Botswana": { "side": "left", "switched": "", "note": "British colony before 1966." }, "Brazil": { "side": "right", "switched": "1928", "note": "Portuguese colony before 1822." }, "Brunei": { "side": "left", "switched": "", "note": "British protection until 1984." }, "Bulgaria": { "side": "right", "switched": "", "note": "" }, "Burkina Faso": { "side": "right", "switched": "", "note": "Part of French West Africa before 1958." }, "Burundi": { "side": "right", "switched": "", "note": "Belgian colony before 1962. Considering switching to LHT." }, "Cambodia": { "side": "right", "switched": "", "note": "French protectorate before 1953." }, "Cameroon": { "side": "right", "switched": "1961", "note": "" }, "Cape Verde": { "side": "right", "switched": "1928", "note": "Portuguese colony before 1975." }, "Central African Republic": { "side": "right", "switched": "", "note": "French colonies before 1960." }, "Chad": { "side": "right", "switched": "", "note": "French colonies before 1960." }, "Chile": { "side": "right", "switched": "", "note": "" }, "Colombia": { "side": "right", "switched": "", "note": "" }, "Comoros": { "side": "right", "switched": "", "note": "French colony before 1975." }, "Congo": { "side": "right", "switched": "", "note": "French colony before 1960." }, "DR Congo": { "side": "right", "switched": "", "note": "Belgian colony before 1960. RHD vehicles are common, especially in the southeast." }, "Costa Rica": { "side": "right", "switched": "", "note": "" }, "Croatia": { "side": "right", "switched": "1926", "note": "Was then part of the Kingdom of Serbs, Croats and Slovenes." }, "Cuba": { "side": "right", "switched": "", "note": "" }, "Cyprus": { "side": "left", "switched": "", "note": "Under UK administration before 1960. Island nation. De facto divided between the Republic of Cyprus, the Turkish Republic of Northern Cyprus, the UN buffer zone and the British base areas of Akrotiri and Dhekelia. All are LHT." }, "Czech Republic": { "side": "right", "switched": "1939", "note": "Switched during the German occupation of Czechoslovakia." }, "Denmark": { "side": "right", "switched": "1793", "note": "Includes the Faroe Islands and Greenland. Danish West Indies were LHT, and continue to be LHT as United States Virgin Islands after US purchase in 1917." }, "Djibouti": { "side": "right", "switched": "", "note": "French colony before 1977." }, "Dominica": { "side": "left", "switched": "", "note": "British colony before 1978. Caribbean island." }, "Dominican Republic": { "side": "right", "switched": "", "note": "Under Spanish control until 1821 and again from 1861 to 1865." }, "Ecuador": { "side": "right", "switched": "", "note": "" }, "Egypt": { "side": "right", "switched": "", "note": "" }, "El Salvador": { "side": "right", "switched": "", "note": "" }, "Equatorial Guinea": { "side": "right", "switched": "", "note": "Spanish colony before 1968." }, "Eritrea": { "side": "right", "switched": "8 June 1964", "note": "Italian colony before 1942." }, "Estonia": { "side": "right", "switched": "", "note": "" }, "Eswatini": { "side": "left", "switched": "", "note": "British protectorate until 1968. Continues to drive on the same side as neighbouring countries." }, "Ethiopia": { "side": "right", "switched": "8 June 1964", "note": "" }, "Fiji": { "side": "left", "switched": "", "note": "The island nation was a British colony before 1970." }, "Finland": { "side": "right", "switched": "8 June 1858", "note": "" }, "France": { "side": "right", "switched": "1792", "note": "Includes French Polynesia, New Caledonia, Saint Pierre and Miquelon, Wallis and Futuna, French Guiana, Réunion, Saint Barthélemy, the Collectivity of Saint Martin, Guadeloupe, and Mayotte." }, "Gabon": { "side": "right", "switched": "", "note": "French colony before 1960." }, "Gambia": { "side": "right", "switched": "1 October 1965", "note": "British colony until 1965. Switched to RHT on 1 October 1965 being surrounded by the former French colony of Senegal." }, "Georgia": { "side": "right", "switched": "", "note": "The bus line on Ilia Chavchavadze Avenue runs on the left." }, "Germany": { "side": "right", "switched": "", "note": "" }, "Ghana": { "side": "right", "switched": "4 August 1974", "note": "British colony until 1957. Ghana switched to RHT in 1974, a Twi language slogan was \"Nifa, Nifa Enan\" or \"Right, Right, Fourth\". Ghana has also banned RHD vehicles – it prohibited new registrations of RHD vehicles after 1 August 1974, three days before the traffic change." }, "Gibraltar": { "side": "right", "switched": "1929", "note": "Gibraltar is RHT because of its land border with Spain." }, "Greece": { "side": "right", "switched": "1926", "note": "Originally LHT (albeit unofficially) since independence. The establishment of the traffic code switched traffic officially to RHT traffic in 1926." }, "Grenada": { "side": "left", "switched": "", "note": "British colony before 1974. Caribbean island." }, "Guatemala": { "side": "right", "switched": "", "note": "" }, "Guinea": { "side": "right", "switched": "", "note": "Part of French West Africa before 1958." }, "Guinea-Bissau": { "side": "right", "switched": "1928", "note": "Portuguese colony until 1974. Drives on the same side as its neighbours." }, "Guyana": { "side": "left", "switched": "", "note": "British colony until 1966. One of the only two countries in continental America which are in LHT, the other being Suriname." }, "Haiti": { "side": "right", "switched": "", "note": "French colony until 1804." }, "Honduras": { "side": "right", "switched": "", "note": "" }, "Hungary": { "side": "right", "switched": "1941", "note": "Originally LHT, like most of Austria-Hungary, but switched sides during World War II." }, "Iceland": { "side": "right", "switched": "26 May 1968", "note": "This Atlantic island nation changed to RHT on H-dagurinn. Most passenger cars were already LHD." }, "India": { "side": "left", "switched": "", "note": "Part of British India before 1947." }, "Indonesia": { "side": "left", "switched": "", "note": "Roads and railways were built by the Dutch, with LHT for roads to conform to British and Japanese standards and RHT for railways to conform with Dutch standards. Urban railways also use RHT. Did not change sides, unlike the Netherlands, in 1906." }, "Iran": { "side": "right", "switched": "", "note": "" }, "Iraq": { "side": "right", "switched": "", "note": "" }, "Ireland": { "side": "left", "switched": "", "note": "What is now the Republic of Ireland was part of the United Kingdom before 1922. The Republic covers most of the island of Ireland; the rest of Ireland is part of Northern Ireland, which remains part of the United Kingdom, which is also LHT." }, "Israel": { "side": "right", "switched": "", "note": "" }, "Italy": { "side": "right", "switched": "1924–26", "note": "" }, "Ivory Coast": { "side": "right", "switched": "", "note": "Part of French West Africa before 1960." }, "Jamaica": { "side": "left", "switched": "", "note": "British colony before 1962. Caribbean island. Most passenger vehicles are RHD, tractor-trailers and other heavy-duty trucks are mostly LHD due to being imported from the United States." }, "Japan": { "side": "left", "switched": "", "note": "LHT was enacted in law in 1924. One of the few non-British-colony countries to use LHT. Okinawa Prefecture was RHT from 24 June 1945 to 30 July 1978 because of American rule." }, "Jordan": { "side": "right", "switched": "", "note": "" }, "Kazakhstan": { "side": "right", "switched": "", "note": "" }, "Kenya": { "side": "left", "switched": "", "note": "Part of the British East Africa Protectorate before 1963." }, "Kiribati": { "side": "left", "switched": "", "note": "This Pacific island nation was a British colony before 1979." }, "Kosovo": { "side": "right", "switched": "", "note": "" }, "Kuwait": { "side": "right", "switched": "", "note": "British Protectorate until 1961." }, "Kyrgyzstan": { "side": "right", "switched": "", "note": "In 2012, over 20,000 cheap used RHD cars were imported from Japan." }, "Laos": { "side": "right", "switched": "", "note": "French protectorate until 1953. The First Thai–Lao Friendship Bridge is LHT in connection to Thailand." }, "Latvia": { "side": "right", "switched": "", "note": "" }, "Lebanon": { "side": "right", "switched": "", "note": "French Mandate of Lebanon before 1946." }, "Lesotho": { "side": "left", "switched": "", "note": "British protectorate from 1885 to 1966. Enclave of LHT South Africa." }, "Liberia": { "side": "right", "switched": "", "note": "Was under American control." }, "Libya": { "side": "right", "switched": "", "note": "Italian colony from 1911 to 1947." }, "Liechtenstein": { "side": "right", "switched": "", "note": "Landlocked between Switzerland and Austria." }, "Lithuania": { "side": "right", "switched": "", "note": "" }, "Luxembourg": { "side": "right", "switched": "", "note": "" }, "Madagascar": { "side": "right", "switched": "", "note": "This island nation was a French colony until 1958." }, "Malawi": { "side": "left", "switched": "", "note": "British colony before 1964." }, "Malaysia": { "side": "left", "switched": "", "note": "British colony before 1957." }, "Maldives": { "side": "left", "switched": "", "note": "This island nation was a British colony before 1965." }, "Mali": { "side": "right", "switched": "", "note": "Part of French West Africa before 1960." }, "Malta": { "side": "left", "switched": "", "note": "British colony before 1964. Island nation." }, "Marshall Islands": { "side": "right", "switched": "", "note": "Was under American control until 1979." }, "Mauritania": { "side": "right", "switched": "", "note": "Part of French West Africa before 1960. Mining roads between Fderîck and Zouérat are LHT." }, "Mauritius": { "side": "left", "switched": "", "note": "This island nation was a British colony before 1968." }, "Mexico": { "side": "right", "switched": "", "note": "" }, "Federated States of Micronesia": { "side": "right", "switched": "", "note": "Was under American control until 1979." }, "Moldova": { "side": "right", "switched": "", "note": "" }, "Monaco": { "side": "right", "switched": "", "note": "Was under French control." }, "Mongolia": { "side": "right", "switched": "", "note": "" }, "Montenegro": { "side": "right", "switched": "", "note": "" }, "Morocco": { "side": "right", "switched": "", "note": "Under French and Spanish protection until 1956." }, "Mozambique": { "side": "left", "switched": "", "note": "Portuguese colony until 1975. Drives on the same side as its neighbours." }, "Myanmar": { "side": "right", "switched": "6 December 1970", "note": "British colony until 1948. Switched to RHT under the orders of Ne Win. Theories emerge on the reasoning behind this switch; one claimed that he met an astrologer that recommended him to switch the country's traffic to the right in order to make the nation prosper, while another claimed that international visits caused him to notice that most countries are RHT and so decided to convert the country's handedness of traffic in order to connect Myanmar's roads with other countries' roads in the future." }, "Namibia": { "side": "left", "switched": "1920", "note": "When South Africa occupied German South West Africa in World War I, it switched to LHT. South West Africa was administered by South Africa 1920–1990." }, "Nauru": { "side": "left", "switched": "1918", "note": "This island nation was administered by Australia until 1968." }, "Nepal": { "side": "left", "switched": "", "note": "Shares open land border with LHT India." }, "Netherlands": { "side": "right", "switched": "1 January 1906", "note": "Includes Aruba, Curaçao, and Sint Maarten." }, "New Zealand": { "side": "left", "switched": "", "note": "These Pacific islands, including territories Niue and Cook Islands, were former British colonies." }, "Nicaragua": { "side": "right", "switched": "", "note": "" }, "Niger": { "side": "right", "switched": "", "note": "Part of French West Africa before 1958." }, "Nigeria": { "side": "right", "switched": "2 April 1972", "note": "British colony until 1960. Under the military government, it switched to RHT due to being surrounded by RHT former French colonies." }, "North Korea": { "side": "right", "switched": "1946", "note": "Was LHT during the period of Japanese rule. Switched to RHT after the Surrender of Japan." }, "North Macedonia": { "side": "right", "switched": "", "note": "" }, "Norway": { "side": "right", "switched": "1793", "note": "" }, "Oman": { "side": "right", "switched": "", "note": "" }, "Pakistan": { "side": "left", "switched": "", "note": "Part of British India before 1947." }, "Palau": { "side": "right", "switched": "", "note": "Palau was under American control until 1986. Most passenger vehicles are RHD due to them being imported from Australia and Japan." }, "Palestine": { "side": "right", "switched": "", "note": "" }, "Panama": { "side": "right", "switched": "1943", "note": "" }, "Papua New Guinea": { "side": "left", "switched": "", "note": "After Australia occupied German New Guinea during World War I, it switched to LHT." }, "Paraguay": { "side": "right", "switched": "25 January 1945", "note": "" }, "Peru": { "side": "right", "switched": "", "note": "" }, "Philippines": { "side": "right", "switched": "1946", "note": "Was LHT during the Spanish and American colonial periods. Switched to RHT after the Battle of Manila in 1945. RHD vehicles such as imported buses were still used up until the late 1980s. Philippine National Railways switched to RHT in 2010. Nowadays RHD vehicles are illegal to register and operate for ordinary use under Republic Act 8506 of 1998 however RHD vintage vehicles made before 1960 in \"showroom\" condition or off-road specialized vehicles are allowed to be used only for motorsports events." }, "Poland": { "side": "right", "switched": "", "note": "South-eastern Poland (former Austrian Partition) was LHT until the 1920s." }, "Portugal": { "side": "right", "switched": "1928", "note": "Colonies Goa, Macau and Mozambique, which had land borders with LHT countries, did not switch and continue to drive on the left. The Porto Metro uses RHT." }, "Qatar": { "side": "right", "switched": "", "note": "Former British protectorate. Switched to same side as neighbours." }, "Romania": { "side": "right", "switched": "1919", "note": "Regions of Romania (Transylvania, Bukovina, parts of the Banat, Crișana and Maramureș) that were part of Austria-Hungary were LHT until 1919." }, "Russia": { "side": "right", "switched": "", "note": "In the Russian Far East, RHD vehicles are common due to the import of used cars from nearby Japan. The railway between Moscow and Ryazan, the Sormovskaya line in Nizhny Novgorod Metro and the Moskva River cable car use LHT." }, "Rwanda": { "side": "right", "switched": "", "note": "Belgian colony before 1962. Considering switching to LHT." }, "Saint Kitts and Nevis": { "side": "left", "switched": "", "note": "This Caribbean island nation was a British colony before 1983." }, "Saint Lucia": { "side": "left", "switched": "", "note": "This Caribbean island nation was a British colony before 1979." }, "Saint Vincent and the Grenadines": { "side": "left", "switched": "", "note": "This Caribbean island nation was a British colony before 1979." }, "Samoa": { "side": "left", "switched": "7 September 2009", "note": "Despite New Zealand occupying German Samoa during the first World War, the country did not switch to LHT until 2009; this was for economic reasons, to allow cheaper importation of cars from Australia, New Zealand and Japan." }, "San Marino": { "side": "right", "switched": "", "note": "Enclaved state surrounded by Italy." }, "São Tomé and Príncipe": { "side": "right", "switched": "1928", "note": "Portuguese colony until 1975." }, "Saudi Arabia": { "side": "right", "switched": "1942", "note": "" }, "Senegal": { "side": "right", "switched": "", "note": "Part of French West Africa before 1960." }, "Serbia": { "side": "right", "switched": "1926", "note": "(As part of Kingdom of Serbs, Croats and Slovenes). Vojvodina was LHT while part of Austria-Hungary." }, "Seychelles": { "side": "left", "switched": "", "note": "This island nation was a British colony until 1976." }, "Sierra Leone": { "side": "right", "switched": "1 March 1971", "note": "British colony until 1961. Switched to RHT being surrounded by neighbouring former French colonies. Furthermore, it banned the importation of RHD vehicles in 2013." }, "Singapore": { "side": "left", "switched": "", "note": "This island nation was a British colony until 1963. It was also part of Malaysia until 1965." }, "Slovakia": { "side": "right", "switched": "1939–41", "note": "Switched during the German occupation of Czechoslovakia." }, "Slovenia": { "side": "right", "switched": "1926", "note": "(As part of Kingdom of Serbs, Croats and Slovenes.) Officially LHT from 1915 as part of Austria-Hungary." }, "Solomon Islands": { "side": "left", "switched": "", "note": "This island nation was a British protectorate before 1975." }, "Somalia": { "side": "right", "switched": "", "note": "The former British Somaliland had LHT until it formed a union with the former Italian Somaliland which had RHT." }, "South Africa": { "side": "left", "switched": "", "note": "British colony before 1910." }, "South Korea": { "side": "right", "switched": "1946", "note": "Was LHT during the period of Japanese rule. Switched to RHT after the USAMGIK Ordinance 65 came into effect." }, "South Sudan": { "side": "right", "switched": "1973", "note": "Part of Sudan until 2011." }, "Spain": { "side": "right", "switched": "1924", "note": "Up to the 1920s Barcelona was RHT, and Madrid was LHT until 1924. The Madrid Metro still uses LHT." }, "Sri Lanka": { "side": "left", "switched": "", "note": "British Ceylon from 1815 to 1948." }, "Sudan": { "side": "right", "switched": "1973", "note": "Formerly Anglo-Egyptian Sudan, it switched sides 17 years later to match neighbours." }, "Suriname": { "side": "left", "switched": "1920s", "note": "Dutch colony until 1975. One of the only two countries in continental America which are in LHT, the other being Guyana. Did not switch sides, unlike the Netherlands itself." }, "Sweden": { "side": "right", "switched": "3 September 1967", "note": "The day of the switch was known as Dagen H. Most passenger vehicles were already LHD. Railways and the Stockholm Metro did not switch and continue to drive on the left." }, "Switzerland": { "side": "right", "switched": "", "note": "" }, "Syria": { "side": "right", "switched": "", "note": "Was under French control." }, "Taiwan": { "side": "right", "switched": "1946", "note": "Was LHT during the period of Japanese rule. The Republic of China (1912–1949) changed Taiwan to RHT in 1946 along with the rest of China." }, "Tajikistan": { "side": "right", "switched": "", "note": "" }, "Tanzania": { "side": "left", "switched": "", "note": "Was British colony until 1961." }, "Thailand": { "side": "left", "switched": "", "note": "One of the few non-British-colony LHT countries. Shares a long land border with RHT Myanmar, Laos and Cambodia." }, "Timor-Leste": { "side": "left", "switched": "19 July 1976", "note": "Portuguese colony until 1975. Switched to RHT with Portugal in 1928; following its annexation by Indonesia in 1976, it switched back to LHT, which it has retained since independence." }, "Togo": { "side": "right", "switched": "", "note": "Part of French West Africa until 1960." }, "Tonga": { "side": "left", "switched": "", "note": "British protectorate before 1970. Polynesian island nation." }, "Trinidad and Tobago": { "side": "left", "switched": "", "note": "British colony before 1962. Caribbean nation." }, "Tunisia": { "side": "right", "switched": "", "note": "RHT was enforced in the French protectorate of Tunisia from 1881 to 1956." }, "Turkey": { "side": "right", "switched": "", "note": "Except Metrobus, which is usually LHT." }, "Turkmenistan": { "side": "right", "switched": "", "note": "" }, "Tuvalu": { "side": "left", "switched": "", "note": "Formerly a British colony. Became independent in 1978." }, "Uganda": { "side": "left", "switched": "", "note": "Part of British Uganda Protectorate from 1894 until 1962." }, "Ukraine": { "side": "right", "switched": "1922", "note": "Western parts of the country had LHT under Austro-Hungarian Empire" }, "United Arab Emirates": { "side": "right", "switched": "1 September 1966", "note": "Former British protectorate." }, "Uruguay": { "side": "right", "switched": "2 September 1945", "note": "Became LHT in 1918, but as in some other countries in South America, changed to RHT in 1945. A speed limit of 30 km/h (19 mph) was observed until 30 September for safety." }, "Uzbekistan": { "side": "right", "switched": "", "note": "" }, "Vanuatu": { "side": "right", "switched": "", "note": "Co-administered under France and the United Kingdom until 1980." }, "Vatican City": { "side": "right", "switched": "", "note": "Enclave of Rome." }, "Venezuela": { "side": "right", "switched": "", "note": "" }, "Vietnam": { "side": "right", "switched": "", "note": "French colony until 1954. The Long Bien Bridge uses LHT." }, "Western Sahara": { "side": "right", "switched": "", "note": "Spanish colony until 1976." }, "Yemen": { "side": "right", "switched": "1977", "note": "South Yemen, formerly the British colony of Aden, changed to RHT in 1977, having become one of a few communist countries to use LHT. A series of postage stamps commemorating the event was issued. At that time, North Yemen was already RHT." }, "Zambia": { "side": "left", "switched": "", "note": "British colony before 1964." }, "Zimbabwe": { "side": "left", "switched": "", "note": "British colony before 1965 (de facto) or 1980 (de jure)." }, "Canada": { "side": "right", "switched": "", "note": "British Columbia: Interior changed 15 July 1920, Vancouver and the coastal area 1 January 1922; Newfoundland and Labrador: Was a British Dominion until 1949." }, "China": { "side": "right", "switched": "1946", "note": "Parts of China were LHT in the 1930s." }, "Hong Kong": { "side": "left", "switched": "", "note": "part of China; Hong Kong was a British colony from 1841 to 1941 and from 1945 to 1997, when the dependent territory was transferred to China.", "part_of": "China" }, "Macau": { "side": "left", "switched": "", "note": "part of China; Macau was under Portuguese rule until 1999, when the dependent territory was transferred to China.", "part_of": "China" }, "United Kingdom": { "side": "left", "switched": "", "note": "An island nation with a land border with the Republic of Ireland, which is also LHT. Also LHT are the British Overseas Territories of Anguilla, Ascension Island, Montserrat, Saint Helena, and Tristan da Cunha. The Heathrow airport PRT uses RHT." }, "Bermuda": { "side": "left", "switched": "", "note": "part of United Kingdom; Some commercial vehicles are LHD due to imports from the United States, which has RHT.", "part_of": "United Kingdom" }, "British Indian Ocean Territory": { "side": "right", "switched": "", "note": "part of United Kingdom; The largest island, Diego Garcia, was leased to the United States Navy as a military base; the United States is RHT.", "part_of": "United Kingdom" }, "British Virgin Islands": { "side": "left", "switched": "", "note": "part of United Kingdom; Most passenger vehicles are LHD due to imports from the United States, which is RHT.", "part_of": "United Kingdom" }, "Cayman Islands": { "side": "left", "switched": "", "note": "part of United Kingdom; Most passenger vehicles are LHD due to imports from the United States, which has RHT.", "part_of": "United Kingdom" }, "Falkland Islands": { "side": "left", "switched": "", "note": "part of United Kingdom; Briefly switched to RHT during the Falklands War.", "part_of": "United Kingdom" }, "Guernsey": { "side": "left", "switched": "", "note": "part of United Kingdom; Was RHT from 1940 to 1945 due to the German occupation.", "part_of": "United Kingdom" }, "Isle of Man": { "side": "left", "switched": "", "note": "part of United Kingdom", "part_of": "United Kingdom" }, "Jersey": { "side": "left", "switched": "", "note": "part of United Kingdom; Was RHT from 1940 to 1945 due to the German occupation.", "part_of": "United Kingdom" }, "Pitcairn Islands": { "side": "left", "switched": "", "note": "part of United Kingdom; There is no official vehicle registration system.", "part_of": "United Kingdom" }, "South Georgia and the South Sandwich Islands": { "side": "left", "switched": "", "note": "part of United Kingdom; Briefly switched to RHT during the Falklands War.", "part_of": "United Kingdom" }, "Turks and Caicos Islands": { "side": "left", "switched": "", "note": "part of United Kingdom; Most passenger vehicles are LHD due to imports from the United States, which has RHT.", "part_of": "United Kingdom" }, "United States": { "side": "right", "switched": "", "note": "British colonies before 1776 (de facto) or 1783 (de jure)" }, "Alaska": { "side": "right", "switched": "", "note": "part of United States", "part_of": "United States" }, "Hawaii": { "side": "right", "switched": "", "note": "part of United States", "part_of": "United States" }, "Puerto Rico": { "side": "right", "switched": "", "note": "part of United States", "part_of": "United States" }, "U.S. Virgin Islands": { "side": "left", "switched": "", "note": "part of United States; U.S. Virgin Islands, like much of the Caribbean, is LHT and is the only American jurisdiction that still has LHT, because the islands drove on the left when the US purchased the former Danish West Indies in the 1917 Treaty of the Danish West Indies. Most passenger vehicles are LHD due to them being imported from the American mainland.", "part_of": "United States" }, "Guam": { "side": "right", "switched": "", "note": "part of United States", "part_of": "United States" }, "Northern Mariana Islands": { "side": "right", "switched": "", "note": "part of United States", "part_of": "United States" }, "American Samoa": { "side": "right", "switched": "", "note": "part of United States", "part_of": "United States" }, "French Guiana": { "side": "right", "switched": "", "note": "French overseas department", "curated": true }, "Guadeloupe": { "side": "right", "switched": "", "note": "French overseas department", "curated": true }, "Martinique": { "side": "right", "switched": "", "note": "French overseas department", "curated": true }, "Réunion": { "side": "right", "switched": "", "note": "French overseas department", "curated": true }, "Mayotte": { "side": "right", "switched": "", "note": "French overseas department", "curated": true }, "New Caledonia": { "side": "right", "switched": "", "note": "French territory", "curated": true }, "French Polynesia": { "side": "right", "switched": "", "note": "French territory", "curated": true }, "Greenland": { "side": "right", "switched": "", "note": "Denmark", "curated": true }, "Faroe Islands": { "side": "right", "switched": "", "note": "Denmark", "curated": true }, "Aruba": { "side": "right", "switched": "", "note": "Netherlands", "curated": true }, "Curaçao": { "side": "right", "switched": "", "note": "Netherlands", "curated": true }, "Sint Maarten": { "side": "right", "switched": "", "note": "Netherlands", "curated": true }, "Anguilla": { "side": "left", "switched": "", "note": "British territory", "curated": true }, "Montserrat": { "side": "left", "switched": "", "note": "British territory", "curated": true }, "Saint Helena": { "side": "left", "switched": "", "note": "British territory", "curated": true }, "Cook Islands": { "side": "left", "switched": "", "note": "New Zealand associated state", "curated": true }, "Niue": { "side": "left", "switched": "", "note": "New Zealand associated state", "curated": true }, "Tokelau": { "side": "left", "switched": "", "note": "New Zealand territory", "curated": true }, "Canary Islands": { "side": "right", "switched": "", "note": "Spain", "curated": true }, "Azores": { "side": "right", "switched": "", "note": "Portugal", "curated": true }, "Madeira": { "side": "right", "switched": "", "note": "Portugal", "curated": true }, "Svalbard": { "side": "right", "switched": "", "note": "Norway", "curated": true } } -
README.md 2.1 KB
# Lookup data Global lookup tables. `clues.py lookup` reads the JSON in this directory; country tables (plates, area codes, admin divisions) live in region packs (`regions/<cc>/data/`, format in `regions/README.md`). Each file's `_meta` records the source URL, fetch date, and entry count; maintainers refetch with `refresh.py` (global tables and the China pack). | File | Content | Source | Fetched | Entries | License | |---|---|---|---|---|---| | `calling_codes.json` | Country calling codes for countries and regions | [en.wikipedia.org/wiki/List_of_telephone_country_codes](https://en.wikipedia.org/wiki/List_of_telephone_country_codes) | 2026-09-14 | 281 | Derived from Wikipedia, CC BY-SA 4.0 | | `driving_side.json` | Driving side by country | [en.wikipedia.org/wiki/Left-_and_right-hand_traffic](https://en.wikipedia.org/wiki/Left-_and_right-hand_traffic) | 2026-10-08 | 241 | Derived from Wikipedia, CC BY-SA 4.0 | | `territories.json` | Overseas territories and dependencies | [en.wikipedia.org/wiki/List_of_dependent_territories](https://en.wikipedia.org/wiki/List_of_dependent_territories) | 2026-09-14 | 60 | Derived from Wikipedia, CC BY-SA 4.0 | | `country_names.json` | Chinese–English country and region name mapping, with aliases | Hand-compiled | 2026-09-14 | 300 | MIT (with this repository) | Notes: - The Wikipedia-sourced tables are factual data scraped and compiled from the tables in the corresponding articles. Wikipedia text is licensed under CC BY-SA 4.0; these tables are released under the same license, with attribution to Wikipedia and its editors. - `country_names.json` is a hand-compiled mapping table; the keys of `en2zh` match the English spellings used in the other tables, and `aliases` maps short names, traditional-character names, former names, and English abbreviations to `en2zh` keys. - This directory contains no OpenStreetMap data; `gazetteer.py` and `osm.py` query Overpass live. Source names and quoted source notes stay in their original language. Maintained annotations (including curated driving-side notes and unverified municipality hints) are in English; the agent explains source evidence in the user's language. -
territories.json 14.1 KB
{ "_meta": { "source": [ "https://en.wikipedia.org/wiki/List_of_dependent_territories" ], "fetched": "2026-09-14", "count": 60 }, "by_sovereign": { "United Kingdom": [ { "name": "Akrotiri and Dhekelia", "sovereign": "United Kingdom", "region": "Asia", "subregion": "Western Asia", "status": "Overseas territory(Sovereign Base Areas)", "population": "15,700", "area_km2": "254" }, { "name": "Anguilla", "sovereign": "United Kingdom", "region": "Americas", "subregion": "Caribbean", "status": "Overseas territory", "population": "15,100", "area_km2": "91" }, { "name": "Bermuda", "sovereign": "United Kingdom", "region": "Americas", "subregion": "Northern America", "status": "Overseas territory", "population": "70,537", "area_km2": "53.2" }, { "name": "British Virgin Islands", "sovereign": "United Kingdom", "region": "Americas", "subregion": "Caribbean", "status": "Overseas territory", "population": "34,232", "area_km2": "153" }, { "name": "Cayman Islands", "sovereign": "United Kingdom", "region": "Americas", "subregion": "Caribbean", "status": "Overseas territory", "population": "57,268", "area_km2": "264" }, { "name": "Falkland Islands", "sovereign": "United Kingdom", "region": "Americas", "subregion": "South America", "status": "Overseas territory", "population": "2,931", "area_km2": "12,173" }, { "name": "Gibraltar", "sovereign": "United Kingdom", "region": "Europe", "subregion": "Southern Europe", "status": "Overseas territory", "population": "29,328", "area_km2": "6.5" }, { "name": "Guernsey", "sovereign": "United Kingdom", "region": "Europe", "subregion": "Northern Europe", "status": "Crown Dependency", "population": "63,026", "area_km2": "65" }, { "name": "Isle of Man", "sovereign": "United Kingdom", "region": "Europe", "subregion": "Northern Europe", "status": "Crown Dependency", "population": "88,195", "area_km2": "572" }, { "name": "Jersey", "sovereign": "United Kingdom", "region": "Europe", "subregion": "Northern Europe", "status": "Crown Dependency", "population": "98,069", "area_km2": "118.2" }, { "name": "Montserrat", "sovereign": "United Kingdom", "region": "Americas", "subregion": "Caribbean", "status": "Overseas territory", "population": "5,267", "area_km2": "101" }, { "name": "Pitcairn Islands", "sovereign": "United Kingdom", "region": "Oceania", "subregion": "Polynesia", "status": "Overseas territory", "population": "57", "area_km2": "43" }, { "name": "Saint Helena, Ascension and Tristan da Cunha", "sovereign": "United Kingdom", "region": "Africa", "subregion": "Western Africa", "status": "Overseas territory", "population": "5,633", "area_km2": "394" }, { "name": "Turks and Caicos Islands", "sovereign": "United Kingdom", "region": "Americas", "subregion": "Caribbean", "status": "Overseas territory", "population": "51,430", "area_km2": "430" } ], "Finland": [ { "name": "Åland", "sovereign": "Finland", "region": "Europe", "subregion": "Northern Europe", "status": "Autonomous region", "population": "29,013", "area_km2": "1,580" } ], "United States": [ { "name": "American Samoa", "sovereign": "United States", "region": "Oceania", "subregion": "Polynesia", "status": "Unincorporated unorganized territory", "population": "54,194", "area_km2": "199" }, { "name": "Guam", "sovereign": "United States", "region": "Oceania", "subregion": "Micronesia", "status": "Unincorporated organized territory", "population": "162,742", "area_km2": "544" }, { "name": "Northern Mariana Islands", "sovereign": "United States", "region": "Oceania", "subregion": "Micronesia", "status": "Unincorporated organized territory(Commonwealth)", "population": "53,467", "area_km2": "464" }, { "name": "Puerto Rico", "sovereign": "United States", "region": "Americas", "subregion": "Caribbean", "status": "Unincorporated organized territory(Commonwealth)", "population": "3,411,307", "area_km2": "9,104" }, { "name": "U.S. Virgin Islands", "sovereign": "United States", "region": "Americas", "subregion": "Caribbean", "status": "Unincorporated organized territory", "population": "102,951", "area_km2": "346.36" }, { "name": "Hawaii", "sovereign": "United States", "region": "Oceania", "subregion": "Polynesia", "status": "State (integral part of the US)", "population": "", "area_km2": "", "curated": true }, { "name": "Alaska", "sovereign": "United States", "region": "Americas", "subregion": "Northern America", "status": "State (integral part of the US)", "population": "", "area_km2": "", "curated": true } ], "Netherlands": [ { "name": "Aruba", "sovereign": "Netherlands", "region": "Americas", "subregion": "Caribbean", "status": "Constituent country", "population": "113,648", "area_km2": "178.91" }, { "name": "Curaçao", "sovereign": "Netherlands", "region": "Americas", "subregion": "Caribbean", "status": "Constituent country", "population": "158,986", "area_km2": "444" }, { "name": "Sint Maarten", "sovereign": "Netherlands", "region": "Americas", "subregion": "Caribbean", "status": "Constituent country", "population": "41,486", "area_km2": "37" }, { "name": "Bonaire", "sovereign": "Netherlands", "region": "Americas", "subregion": "Caribbean", "status": "Special municipality (integral part of the Netherlands)", "population": "", "area_km2": "", "curated": true }, { "name": "Sint Eustatius", "sovereign": "Netherlands", "region": "Americas", "subregion": "Caribbean", "status": "Special municipality", "population": "", "area_km2": "", "curated": true }, { "name": "Saba", "sovereign": "Netherlands", "region": "Americas", "subregion": "Caribbean", "status": "Special municipality", "population": "", "area_km2": "", "curated": true } ], "Australia": [ { "name": "Christmas Island", "sovereign": "Australia", "region": "Oceania", "subregion": "Australia and New Zealand", "status": "External territory", "population": "2,205", "area_km2": "135" }, { "name": "Cocos (Keeling) Islands", "sovereign": "Australia", "region": "Oceania", "subregion": "Australia and New Zealand", "status": "External territory", "population": "596", "area_km2": "14" }, { "name": "Norfolk Island", "sovereign": "Australia", "region": "Oceania", "subregion": "Australia and New Zealand", "status": "External territory", "population": "2,210", "area_km2": "34.6" } ], "New Zealand": [ { "name": "Cook Islands", "sovereign": "New Zealand", "region": "Oceania", "subregion": "Polynesia", "status": "Associated state", "population": "18,100", "area_km2": "240" }, { "name": "Niue", "sovereign": "New Zealand", "region": "Oceania", "subregion": "Polynesia", "status": "Associated state", "population": "1,190", "area_km2": "261.46" }, { "name": "Tokelau", "sovereign": "New Zealand", "region": "Oceania", "subregion": "Polynesia", "status": "Dependent territory", "population": "1,499", "area_km2": "10" } ], "Denmark": [ { "name": "Faroe Islands", "sovereign": "Denmark", "region": "Europe", "subregion": "Northern Europe", "status": "Autonomous territory", "population": "49,188", "area_km2": "4,167" }, { "name": "Greenland", "sovereign": "Denmark", "region": "Americas", "subregion": "Northern America", "status": "Autonomous territory", "population": "56,483", "area_km2": "2,166,086" } ], "France": [ { "name": "French Polynesia", "sovereign": "France", "region": "Oceania", "subregion": "Polynesia", "status": "Overseas collectivity(Overseas country)", "population": "285,735", "area_km2": "1,359" }, { "name": "New Caledonia", "sovereign": "France", "region": "Oceania", "subregion": "Melanesia", "status": "Sui generis collectivity", "population": "275,355", "area_km2": "18,576" }, { "name": "Saint Barthélemy", "sovereign": "France", "region": "Americas", "subregion": "Caribbean", "status": "Overseas collectivity", "population": "7,209", "area_km2": "25" }, { "name": "Saint Martin", "sovereign": "France", "region": "Americas", "subregion": "Caribbean", "status": "Overseas collectivity", "population": "31,949", "area_km2": "53.2" }, { "name": "Saint Pierre and Miquelon", "sovereign": "France", "region": "Americas", "subregion": "Northern America", "status": "Overseas collectivity", "population": "5,595", "area_km2": "242" }, { "name": "Wallis and Futuna", "sovereign": "France", "region": "Oceania", "subregion": "Polynesia", "status": "Overseas collectivity", "population": "15,664", "area_km2": "142" }, { "name": "French Guiana", "sovereign": "France", "region": "Americas", "subregion": "South America", "status": "Overseas department and region (integral part of France, EU)", "population": "", "area_km2": "", "curated": true }, { "name": "Guadeloupe", "sovereign": "France", "region": "Americas", "subregion": "Caribbean", "status": "Overseas department and region", "population": "", "area_km2": "", "curated": true }, { "name": "Martinique", "sovereign": "France", "region": "Americas", "subregion": "Caribbean", "status": "Overseas department and region", "population": "", "area_km2": "", "curated": true }, { "name": "Réunion", "sovereign": "France", "region": "Africa", "subregion": "Eastern Africa", "status": "Overseas department and region", "population": "", "area_km2": "", "curated": true }, { "name": "Mayotte", "sovereign": "France", "region": "Africa", "subregion": "Eastern Africa", "status": "Overseas department and region", "population": "", "area_km2": "", "curated": true } ], "China": [ { "name": "Hong Kong", "sovereign": "China", "region": "Asia", "subregion": "Eastern Asia", "status": "Special administrative region", "population": "7,374,000", "area_km2": "2,755" }, { "name": "Macau", "sovereign": "China", "region": "Asia", "subregion": "Eastern Asia", "status": "Special administrative region", "population": "650,900", "area_km2": "115.3" } ], "Norway": [ { "name": "Svalbard", "sovereign": "Norway", "region": "Europe", "subregion": "Northern Europe", "status": "Unincorporated area", "population": "2,667", "area_km2": "61,022" } ], "Spain": [ { "name": "Canary Islands", "sovereign": "Spain", "region": "Africa", "subregion": "Northern Africa (Atlantic)", "status": "Autonomous community (integral part of Spain)", "population": "", "area_km2": "", "curated": true }, { "name": "Ceuta", "sovereign": "Spain", "region": "Africa", "subregion": "Northern Africa", "status": "Autonomous city (integral part of Spain)", "population": "", "area_km2": "", "curated": true }, { "name": "Melilla", "sovereign": "Spain", "region": "Africa", "subregion": "Northern Africa", "status": "Autonomous city (integral part of Spain)", "population": "", "area_km2": "", "curated": true } ], "Portugal": [ { "name": "Azores", "sovereign": "Portugal", "region": "Europe", "subregion": "Southern Europe (Atlantic)", "status": "Autonomous region (integral part of Portugal)", "population": "", "area_km2": "", "curated": true }, { "name": "Madeira", "sovereign": "Portugal", "region": "Europe", "subregion": "Southern Europe (Atlantic, off Africa)", "status": "Autonomous region (integral part of Portugal)", "population": "", "area_km2": "", "curated": true } ], "Chile": [ { "name": "Easter Island", "sovereign": "Chile", "region": "Oceania", "subregion": "Polynesia", "status": "Special territory (integral part of Chile)", "population": "", "area_km2": "", "curated": true } ], "Ecuador": [ { "name": "Galápagos Islands", "sovereign": "Ecuador", "region": "Americas", "subregion": "South America (Pacific)", "status": "Province (integral part of Ecuador)", "population": "", "area_km2": "", "curated": true } ], "India": [ { "name": "Andaman and Nicobar Islands", "sovereign": "India", "region": "Asia", "subregion": "Southern Asia (Bay of Bengal)", "status": "Union territory (integral part of India)", "population": "", "area_km2": "", "curated": true } ], "Russia": [ { "name": "Kaliningrad Oblast", "sovereign": "Russia", "region": "Europe", "subregion": "Eastern Europe (exclave on the Baltic)", "status": "Oblast (integral part of Russia)", "population": "", "area_km2": "", "curated": true } ], "Japan": [ { "name": "Okinawa", "sovereign": "Japan", "region": "Asia", "subregion": "Eastern Asia", "status": "Prefecture (integral part of Japan)", "population": "", "area_km2": "", "curated": true } ] }, "count": 60, "note": "维基“属地列表”不含法国海外省这类本土一体化的海外领土,INTEGRAL_OVERSEAS 补上(curated=true)" }
-
-
references
-
clues
-
global.md 7.7 KB
# Cross-country clues Clues that tell countries and continents apart, or that work the same way in many countries. Clues that only narrow things down inside one country live in that country's region pack (`regions/<cc>/clues.md`; `regions.py list` shows which exist). General order when the country is still open: driving side and plate shape to exclude continents → script and language of the text, phone calling codes → plate style to the state/province → infrastructure and municipal fixtures → architecture and vegetation. ## General ### Driving side, driver's seat - Look for: whether the driver's seat is on the left or the right; which side of the road traffic keeps to; which way cars parked at the roadside face - Points to: right-hand traffic excludes the UK, Ireland, Japan, Australia, New Zealand, South Africa, India, some Southeast Asian countries, and Hong Kong/Macau; and vice versa - Strength: strong (exclusion type; one clue excludes a batch of countries) - Counterexamples: left-hand-traffic countries have a few imported left-hand-drive cars; **the photo or video may be mirrored**, first check for reversed text - Sources: v009 v010-4 ### Plate aspect ratio - Look for: the plate's shape; no need to read the characters - Points to: long narrow strip → Europe; rectangle close to 2:1 → North America; refine further by background color and graphics - Strength: medium - Counterexamples: Japanese plates are also close to 2:1 (exclude them with the driver's seat side); some European vehicles carry square plates; motorcycle plates are generally squarish - Sources: v009 ### Plate background and graphics in countries that issue plates by state/province - Look for: when the characters can't be read: the plate's overall background color, color bands, central graphic (state or provincial flag), border color - Points to: state/province level (US, Mexico, Canada, Brazil's old format, Australia, etc.) - Strength: medium (filter to a few states against a chart of state plate designs; narrows by an order of magnitude in one step) - Counterexamples: out-of-state vehicles crossing state lines (especially common on freight corridors and in border cities); old and new formats coexist, and reference charts go out of date; overexposure in strong light renders light-colored graphics as white - Sources: v002 v009 ### Official bilingual or multilingual signs - Look for: two languages with the same content on one official traffic sign or street-name sign - Points to: an officially multilingual admin area, not "near a country that speaks that language" (e.g., German-Italian bilingual signs in Italy → Province of Bolzano/South Tyrol) - Strength: strong - Counterexamples: bilingual private shop signs don't count; foreign-language signs added for tourists in tourist areas don't count - Sources: v001 ### Sub-brands and business lines of chain brands - Look for: small marks on the sign besides the parent brand (truck tire retreading, commercial vehicle service, etc.) - Points to: sub-brands have far fewer stores, so listing all their stores nationwide gives a candidate point list; also suggests a freight corridor or the outskirts of an industrial zone - Strength: weak (alone); medium together with a store search - Counterexamples: store data on maps is incomplete - Sources: v002 ### Ads for regional consumer goods - Look for: drink and snack brand ads on vehicles and along the street - Points to: the country or region where the brand mainly sells - Strength: weak - Counterexamples: multinational brands, imported brands - Sources: v007 ### Bus operator abbreviation + route number - Look for: the operator abbreviation and route number at the top of the bus front and rear - Points to: city; route number → look up the route corridor with `osm.py route` and search along it - Strength: strong (to the city); with a route map, to one line - Counterexamples: second-hand imported buses keep text from the country of origin on the body, so it can't be used to judge the country; route numbers get changed - Sources: v007 ## Europe ### Roof color: red terracotta tile vs gray pitched roofs (northern Italy, etc.) - Look for: the color of roofs across an area in distant views of the photo or in satellite imagery - Points to: Italian-speaking areas are mostly red terracotta; many gray or dark pitched roofs → towns in the German-speaking cultural area (Austria, Germany, South Tyrol) - Strength: weak (meaningful only as a statistic over a whole area) - Counterexamples: new apartment buildings often use flat roofs or gray metal roofs - Sources: v001 ### Arch form: round or pointed - Look for: whether door and window arches are round or pointed; biforate windows (two small arches inside one large arch) - Points to: round arch → Romanesque or Renaissance; pointed arch → Gothic. If sources say a city has few medieval remains but the photo shows an intact "medieval courtyard" → prefer 19th-century-or-later replicas (exposition pavilions, mock-historic streets, film studio sets) - Strength: weak (narrows the building type, helps choose search terms) - Counterexamples: there are many replicas; reverse image search in a Chinese-language interface mixes in large numbers of modern-era Chinese red-brick buildings - Sources: v004 ### Uniform streetlight design within an area - Look for: streetlight pole color, lamp head shape - Points to: procured uniformly by one city or area; use it for spot-check confirmation between candidate towns - Strength: weak (for verifying, not for finding) - Counterexamples: one manufacturer's lights are sold to many cities - Sources: v001 v009 ## Inherited standards (overseas territories, former colonies) When you recognize infrastructure built to "country X's standard", the candidates are the home country + overseas territories that keep the home country's standards + some former colonies. Intersect that with the IP location and the continent the puzzle setter named, and often only one place remains. ### Arched crossarms on the French distribution grid - Look for: a crossarm arching upward on top of a concrete or metal pole, three conductors hanging from suspension insulators (the "arched" layout of medium-voltage lines); identical poles lined up into the distance - Points to: the French grid system: metropolitan France and the overseas departments that keep its standards; some former North African colonies have similar pole types - Strength: medium (narrows the whole world to a few regions in the French system; then use IP, continent, and climate to fix one) - Counterexamples: border areas of neighboring countries have similar styles; former colonies later changed pole types; in small distant images an ordinary straight crossarm is easily seen as arched, so zoom in to verify - Sources: v013 ## South Asia ### Sinhala script on official signs (Sri Lanka) - Look for: Sinhala script (rounded, looping letters with no line along the top, unlike Hindi script) on road signs, street-name boards and government signs; official signs often repeat the same text in Sinhala, Tamil and English, but many carry only one or two of the three - Points to: Sri Lanka; Sinhala is an official language there and is written almost nowhere else - Strength: medium - Counterexamples: Sinhala on temples, shops or embassies outside Sri Lanka; Malayalam (Kerala, India) is also rounded and can pass for Sinhala at low resolution; when only Tamil and English are visible (Sinhala cropped, blurred or absent from that sign), the sign can be mistaken for Tamil Nadu, India, so Tamil alone does not exclude Sri Lanka; private shop signs follow no official layout - Sources: Constitution of Sri Lanka, Articles 18–19 (https://www.constituteproject.org/constitution/Sri_Lanka_2015.pdf?lang=en); Wikipedia "Sinhala script" (https://en.wikipedia.org/wiki/Sinhala_script) -
README.md 2.3 KB
# Clue library Check against it in steps 2 and 4. Entry sources are marked `vNNN` (the number of a creator video the author broke down; the breakdown notes are not public) or `case` (a real case). ## Where clues live | Place | Scope | |---|---| | `global.md` (this folder) | Cross-country: telling countries and continents apart (driving side, plate shape, multilingual signs), Europe-wide patterns, South Asia, inherited standards | | `regions/<cc>/clues.md` | Inside one country: plates and codes to the province/state, local vehicles, infrastructure, climate, urban form. `regions.py show <cc>` prints a one-line index with line numbers | Rule for placing a new entry: if its "Points to" stays inside one country, it goes into that country's pack; if it separates countries, it goes into `global.md`. ## Entry format ```markdown ### <clue name> - Look for: exactly what to find in the image and how to recognize it - Points to: country / province / city / type of area within a city - Strength: strong (a single clue reaches this level) / medium (needs one more) / weak (can only exclude or boost) - Counterexamples: when it leads to a wrong call - Sources: vNNN or case ``` ## Rules - Every entry must state strength and counterexamples. A clue with no counterexample usually just hasn't been seen to mislead yet; that doesn't make it reliable. - A clue can be marked "strong" only when three or more sources mention it independently; for a single source, write "(single source)". - **Only include clues that transfer to new photos.** What one particular landmark looks like (a certain bridge, fountain, or building) doesn't go into the library: it won't help with the next photo, and it contaminates evaluations built from video puzzles. - **Counterexamples and source notes also never state a specific puzzle's place names or conclusion** (writing "puzzle X's IP was in province A, actually in city B" amounts to writing the answer into the skill). When citing a puzzle, state only method-level facts ("the photo was in a province neighboring the IP province"). The same goes for script examples: use places unrelated to eval puzzles, or `<placeholders>`. - Strength changes with context: the same plate is strong evidence on the street and weak evidence in a parking lot; vegetation without a month is always weak. - Write methods in your own words; don't copy the video's wording verbatim.
-
-
aerial.md 6.1 KB
# Aerial branch: airplane-window photos, drone top-down shots Ground-photo signs, license plates and street view are all useless here; what works is **aircraft registration → flight track**, or **landforms + hydrology → river section → line-to-point → 3D terrain comparison**. Sources: v008 (large-river gorge from an airplane window), v012 (frozen lake through a cabin window + registration), v010-2 (gorge by drone). ## 1. Look for the aircraft registration first The registration on the wing's upper and lower surfaces, engine nacelles and rear fuselage (Chinese civil aviation: `B-` plus 4 characters; seen from a cabin window it's often upside down, so check each character). 1. Look up this aircraft's flight history by registration on Flightradar24 or FlightAware (the free tier covers about 7 days; longer needs a paid tier; the paid tier can download KML/CSV tracks for single flights). 2. **Search the time window backward from the posting time**: casual photos were most likely taken the same day or a few days before (in v012 the correct flight was the one on the posting day, yet the creator went through all 114 flights over two months). If EXIF has a capture time, use it directly. 3. Filter the flight list layer by layer with conditions directly visible in the photo: - Day/night: takeoff and landing times against sunrise and sunset - Climate: the latitude band matching ice, snow cover, vegetation color - Terrain: whether the route passes over this kind of mountains/plains - Heading: the flight direction computed from the sun (see section 3) 4. Export the tracks of the remaining flights and look only at features on **the window side**, within visual range (tens of kilometers at cruising altitude). Without a registration, cruising altitude, heading and common airways (which routes pass over this area) can serve as weak supporting evidence. ## 2. Which side you're sitting on, which way the nose points - The wing's **trailing edge** has flap tracks and a row of fairings; the leading edge is smooth. Whichever side of the frame the trailing edge faces, the tail is on that side. - Nose toward the left of the frame = right-side window; toward the right of the frame = left-side window. - This determines which side of the flight track the ground features are on. ## 3. Infer heading from the sun (must be computed for the time) - Whichever side the ridge shadows fall on, the sun is on the other side; lit slopes and reflections on water also work. - **Don't assume "shadows point north"**: north of the Tropic of Cancer, shadows point due north only at noon; at 37° N at 10 a.m. in summer the shadow is already off by about 70°, and on winter mornings shadows are closer to northwest. - With candidate flights, compute the sun azimuth at the time each one passes over the area (`sun.py pos --at … --time …`), then compare it with the lighting direction in the photo; this can tell right from wrong among several candidate flights (in v012 it could have been settled without asking the puzzle setter). - When the time is completely unknown, direction is only a weak constraint and can't be used to exclude candidates. ## 4. No registration: landforms + hydrology 1. Identify the river (width, color, landforms on both banks) → list similar river sections → exclude by orientation and bank terrain → line-to-point (bridges, dams, ferries) → go through the candidate table one by one. Steps and commands are in `corridors.md` section 5. 2. For the **only man-made object** in the frame (a regular, light-colored patch on a ridge, a row of colored roofs, a tunnel portal), first describe its shape and position; don't label it "ordinary village" and discard it. It's often the anchor: - Crop it and search on short-video platforms and Baidu image search (v010-2 hit the scenic area's official account) - After locating it, check its name and size against map POIs + local news (v008) 3. Once the anchor is fixed, find a tangent line like "ridge — feature edge" in the photo and draw the same line on satellite imagery; the camera position is on that line (v010-2). ## 5. 3D terrain comparison (replaces street view) On pure top-down satellite imagery, loess gullies and mountains all look alike; lower the view to near the photo's depression angle and the differences show. ```bash # airplane window: give absolute altitude (cruise about 8000–11000 m, a few thousand meters during descent); negative pitch looks down uv run scripts/terrain.py view --at <candidate lat,lon> --alt 9000 --heading 90 --pitch -35 --hfov 60 --range 40000 --zoom 11 --out a.png --photo puzzle.jpg # drone: one or two hundred meters above ground uv run scripts/terrain.py view --at <candidate lat,lon> --height 150 --heading 300 --pitch -20 --hfov 70 --range 15000 --out d.png --photo puzzle.jpg ``` - Compare ridgeline outlines, valley orientations and roads on ridges; for water bodies, compare bulges and inlets in the outline. - High-altitude oblique views flatten shapes along the sight-line direction and skew them slightly; allow for perspective distortion when comparing; when needed, rotate and stretch the photo and overlay it on satellite imagery. - Elevation data is about 30 m per cell: large mountain masses and river valleys are reliable; don't force comparisons of details smaller than a hundred meters. ## 6. Verify "what it is" with environmental data too - Uniformly white water with no reflection, seen from high altitude, is most likely ice or snow on ice; after locating, confirm with the local historical daily temperatures (v012). - Counterexamples: salt lakes, salt pans, dry lakebeds, turbid silty water, sun glint, thin cloud. ## Common mistakes - Going through the entire flight list instead of checking "closest to the posting time first" (v012). - Using "shadows point north" to infer heading or river orientation, and excluding candidates with it as a hard condition (v008, v012). - Listing mountain ranges from memory and missing the one containing the answer (v012 didn't explain why the Lüliang Mountains were left out). - Settling the final pick among a few by asking the puzzle setter, instead of using an independently doable test like sun azimuth (v012). -
corridors.md 19.9 KB
# Linear corridors and structured queries Use when there are no landmarks and no text, but the image shows a **route number, railway, power line, large river or expressway**. The core idea in two sentences: **first turn the area into a line, then look for discrete points on the line**; **when several kinds of infrastructure share the frame, use a query to enumerate every co-occurrence point in the whole province at once**; don't scan the whole map. Sources: v002 v003 v007 v008 v010-3 v011. Script: `scripts/osm.py` (find / near / crossings / route / along / intersect / street-scan / buildings / geom / raw). Output is `{name: [lat, lon]}`, which goes straight into `tiles.py mark` to plot the points and `tiles.py sheet` for numbered candidate thumbnails. OSM data in China is incomplete: a hit is a candidate; a miss can't be used as grounds for exclusion. Known gaps in OSM for China (measured in blind tests): - `building` and `parking` are basically empty in counties and townships, and missing even in many urban areas of prefecture-level cities — **in China, don't use buildings or parking lots as co-occurrence conditions**. - Many rivers have only a centerline and no water-area polygon; the riverbank shape can only come from satellite imagery. - A whole-province query can take several minutes, and public servers also rate-limit; run it in the background, or run provinces separately. - In mountain cities, curves, hairpin turns and retaining walls are everywhere; don't use them as co-occurrence conditions (in a blind test the core of the main urban area had nearly 700 hairpin turns; adding "school, arterial road" still left dozens, none of them right). - **Check coverage before enumerating candidates**: `osm.py coverage --areas <district A>,<district B> --filter '<feature>'`. If one candidate district has noticeably fewer features, the co-occurrence query silently excludes all of it (common in China: almost none of a whole district's schools or running tracks are mapped). ## 1. Route number → corridor The operator abbreviation + route number on top of a bus's rear, bus stop signs, metro line numbers, origin and destination on long-distance coach signs (v007: operator abbreviation + route number on the bus rear → one route corridor). ```bash uv run scripts/osm.py route --bbox <s,w,n,e> --kind bus --ref <route number> --step 150 --out route.json uv run scripts/tiles.py fetch <center of a segment along the route> --zoom 17 --radius 4 --out seg.jpg uv run scripts/tiles.py mark seg.jpg --points route.json --out seg_route.jpg ``` - When OSM doesn't have the route, search "<city> <route number> 路 线路图 / bus route" (路 线路图 = bus route map), then on a map manually record the coordinates of a few stops and connect them. - Before scanning along the route, combine what is "behind the buildings" and "behind the camera" in the photo into filter conditions: construction hoarding or tower cranes showing above the rooftops = a construction site behind; a glass curtain wall reflecting nothing but trees = green space across the street (v007). ## 2. Railways | What you see | What it implies | How to query | |---|---|---| | Railway company name on a level-crossing sign (JR西日本 (JR West), private railway names) | Operating area or specific line | The company's operating area | | Catenary masts on one side only | Usually a single-track electrified railway (a small share nationwide) | `["railway"="rail"]["electrified"="contact_line"]["tracks"="1"]` (tracks is often untagged in China) | | Masts on both sides, or portal gantries spanning the tracks | Double track | — | | 4 tracks side by side at the same level crossing | Quadruple track or several lines running in parallel; only a few sections nationwide | `osm.py crossings --kind level_crossing`: OSM has one crossing node per track, and the script estimates the track count from the node count | | High-speed rail viaduct | Along a high-speed line | `["railway"="rail"]["highspeed"="yes"]["bridge"]` | ```bash # All level crossings on a stretch of railway, by track count (each is labeled "~N tracks") uv run scripts/osm.py crossings --bbox <s,w,n,e> --line '["railway"="rail"]' --kind level_crossing ``` Order for railway scenes: line (company name, track count, electrification) → station (terrain conditions such as by the sea, by a river) → compare each level crossing or bridge along the line against satellite imagery. The web version of OpenRailwayMap (openrailwaymap.org) shows line class and single/double track very clearly; good for manual comparison. ## 3. Power lines - Tower type only separates "transmission / distribution": tall steel-tube monopoles or angle-steel lattice towers with long insulator strings = transmission; short concrete poles or wooden poles = distribution. - Read the voltage from bundled conductors (common in China): single conductor ≈ below 220 kV, bundles of 2 = 220–330 kV, **bundles of 4 = 500 kV**, bundles of 6 = 750 kV or ±800 kV DC, bundles of 8 = 1000 kV. - When you can't count the conductors at a distance, look for small dark dots at intervals along the line (spacers): present → bundled conductors, treat as ≥220 kV; if you can't narrow it further, don't commit to a single voltage. - The voltage can also be looked up in reverse: find the same type of tower in street view at any known location, click that line on OpenInfraMap (openinframap.org) to see its voltage, and you get "this tower type ≈ X kV" (v002). - **Useless alone, useful only in intersection**: only crossing or parallel relationships with railways, rivers and expressways narrow things down. The angles between several linear features (power line perpendicular to the railway, parking spaces perpendicular to the railway) are a spatial signature visible at a glance on satellite imagery (v011). OSM tags: `["power"="line"]["voltage"="500000"]`, `["power"="tower"]`. ## 4. Co-occurrence queries (several kinds of infrastructure in the same frame) Write the features that appear together in the image and have OSM tags as "B within x m of A, C within y m of B", and enumerate over a whole province or city (v010-3: high-speed rail bridges within 700 m of a power tower, rivers within 100 m of the bridge → a few dozen sites in the province → filter by plain/mountain and distant mountain outline). ```bash uv run scripts/osm.py near --area <full name of the province-level division> \ --a '["railway"="rail"]["highspeed"="yes"]["bridge"]' --b '["power"="tower"]' --within 700 \ --c '["waterway"="river"]' --within-c 100 --out cands.json ``` - **Loosen** distance thresholds to double your estimate from the image; better too many than missing one, then filter with terrain, distant mountain outline and satellite imagery. - `--area` takes the full admin-division name used in OSM (e.g. "江苏省" (Jiangsu Province), "深圳市" (Shenzhen City)); if it isn't found, switch to `--bbox`. - Over 200 results: add conditions; don't go through them one by one. - Adding `--report near.json` writes each candidate's actual distance to B and C and its key tags (voltage, electrification, line name), for ranking. - **Rank, then look**: distance to towns and roads (`--rank-near`), whether the voltage class matches the image, whether the actual distance is close to the image estimate, terrain (`terrain.py elev` to see whether the railway is on a slope). - `--rank-near` is only a rough ranking: in China, OSM town nodes, hotels and residential land-use tagging are all uneven (residential land use even includes villages, so ranking by it is almost useless). Town nodes `'["place"~"^(city|town)$"]'` are relatively the most stable; for service areas use `'["highway"="services"]'`. - **What actually narrows candidates is attributes you can verify in the image**: countable bundled conductors → voltage, visible catenary → electrification, the track count at a level crossing. One verified attribute often cuts 80% of candidates, more effective than any distance ranking; if you can't verify it, widen it to a range (e.g. 220–500 kV); don't guess a single value. - **Stopping rule**: view thumbnails in rank order with `tiles.py sheet`; if there's still no match after 100, stop and report "province/city level + candidate areas + why they didn't match"; don't keep scanning by eye. ## 4.1 Crossings of two kinds of lines (railway × power line, river × road) ```bash uv run scripts/osm.py intersect --area <full name of the province-level division> \ --a '["railway"="rail"]["electrified"="contact_line"]' --b '["power"="line"]["voltage"~"^(220000|500000)$"]' \ --bend-min 25 --bend-within 100:900 --rank-near '["place"~"^(city|town)$"]' \ --cluster 600 --out crossings.json # for voltage, write only the range you can verify in the image uv run scripts/tiles.py sheet --points crossings.json --zoom 18 --out crossings_sheet.jpg ``` - `--bend-min`: marks crossings where B has a bend away from the crossing point (angle tower, river bend); by default it **only labels them and ranks them first; it doesn't delete**. - Only `--bend-filter` actually deletes; deleted ones are written to `<out>_dropped.json`. Use it only when "the line turns at this tower, and which side of the crossing the tower is on" is a fact you confirmed with your own eyes in the image; a bend position inferred from a distant view doesn't count. - `--ring lat,lon:min_m:max_m`: keep only crossings within this distance ring around a point (e.g. a distance computed from the landmark's pixel size and the field-of-view range). - Put voltage and electrification into the filter only when you can count the bundled conductors or see the catenary in the image; otherwise loosen them into ranking. - Labels carry the line name, voltage, electrification, bend, and distance to the reference feature; look first at the ones that match the image. ## 4.2 Street-view geometry template (no anchor, but a clear street layout) The photo only narrows to town level, but the layout in the frame is clear: the camera looks down a street, with a large building on the right and a wall and garden on the left. Write the layout as a template and filter the whole town at once: ```bash # camera heading (a range from sun.py facing or shadows) → street bearing 320°–80°; a building with a footprint ≥250 m² 4–22 m to the right; no building 3–10 m to the left uv run scripts/osm.py street-scan --bbox <town s,w,n,e> --bearing 320:80 --right building --left empty \ --band 4:22 --clear 3:10 --ahead 0:40 --min-area 250 --out cands.json uv run scripts/tiles.py sheet --points cands.json --zoom 19 --out cands_sheet.jpg ``` - By default it assumes the camera stands at an intersection looking down the side street; if not at an intersection, add `--anywhere --every 30`. - Regression check: on one video puzzle, a whole town's 2265 roads and 5132 buildings filtered down to 38 candidates; the correct intersection was 3 m from a candidate point. - Only suitable where OSM building data is complete (Europe and North America, Japan, the cores of some large Chinese cities); don't use it in Chinese counties and townships. - Common tags: `["railway"="level_crossing"]`, `["man_made"="water_tower"]`, `["aerialway"]` (aerial ropeways, cable cars), `["bridge"="yes"]`, `["waterway"="dam"]`, `["leisure"="pitch"]`, `["amenity"="fuel"]`, `["man_made"="communications_tower"]`, `["historic"]`, `["tourism"="attraction"]`. ## 4.3 Linear infrastructure × terrain: no text, only infrastructure and an unidentifiable mountain Section 4's co-occurrence queries can only use OSM tags; "a steep mountain in the distance" can't be written into them. The method is to connect each point on the infrastructure to elevation data, compute the horizon, and filter a whole large region at once. This is the single source for the method (one real case: about 27,000 railway-bridge segments in one large region (about 25,000 of them electrified or untagged) → 171 clusters, ground truth among them). 1. `osm.py geom '<infrastructure filter>' --bbox <large region> --out lines.geojson` to get the lines. 2. `terrain.py scan` samples points along the lines, computes a 360° horizon at each point from Terrarium elevation, filters by shape conditions and clusters: ```bash uv run ${CLAUDE_SKILL_DIR}/scripts/terrain.py scan --lines lines.geojson --out hits.json \ [--clusters-out clusters.json] [--bbox s,w,n,e] [--step 400] [--zoom 10] \ [--near-flat 40] [--near-radius 1200] [--near-step 300] \ [--min-peak 4.5] [--max-low 1.2] [--flat-run 20] [--min-low-deg default=--flat-run] [--flat-run-cap 100] \ [--eye 1.5] [--az-step 5] [--dist 1500,2000,2500,3000,3500,4000,5000,6000,7000,8000,9000] \ [--skip-tag electrified=no ...] [--cluster-km 3.5] [--threads 8] [--max-tiles 4000] [--allow-missing] ``` - `--step` is the sampling spacing along the line (meters); `--zoom` is the elevation tile level (at z10 one cell is about 150 m; good enough, with few tiles). - Filter conditions use only shapes that don't depend on heading and are visible in the image: `--near-flat` = relief within `--near-radius` m is at most this many meters (flat nearby), `--min-peak` = within a few km there must be a mountain at elevation angle ≥X°, `--max-low` = how low counts as a flat horizon, `--flat-run` = how many continuous degrees the flat horizon must span. `--dist` is the distance ladder used to compute mountains. - `--skip-tag` skips lines by tag (repeatable); `--cluster-km` is the clustering radius for hits. If the tile count exceeds `--max-tiles` it exits with an error; shrink `--bbox` or lower `--zoom` first. - `--cache` uses the same elevation cache as `terrain.py` (default `.geo-cache/dem`). - Output `hits.json`: `{params:{...}, n_samples, n_hits, n_clusters, hits:[{lat, lon, h0: ground elevation m, max_ang: highest horizon elevation angle °, az: its azimuth °, relief: max relative height difference in that azimuth m, flat_run_deg: length of the flat horizon right next to the mountain °, name/hs/elec/id: the line's OSM tags}...], clusters:[...]}`. Clusters are sorted by `max_ang` descending; a cluster's representative is its steepest point (same fields as `hits`, plus `n` = number of points in the cluster); `--clusters-out` writes a separate JSON with only the cluster list. 3. The resulting clusters go into the batch skyline scoring in `geometry.md` 7.4 (`terrain.py fit --hits`). Without EXIF it searches 13–135mm by default; if the leading clusters all want the longest (or shortest) focal, widen `--f35-range` and rerun before reading the ranking. - **When the mountain is right in front of you** (its foot a few hundred meters from the camera position), the defaults miss it: within the 1200 m `--near-radius` you are sure to climb onto its slope, and `--dist` starting at 1.5 km can't measure this mountain either. Shrink `--near-radius` to a few hundred meters (`--near-step` must be smaller than it, otherwise only the camera position itself is sampled and the flat-nearby check does nothing), and start `--dist` at 400 m. In hilly areas, loosening like this gives many candidates; when the skyline can't separate them, you need a second constraint. - **When mountains fill the whole frame** (no stretch of flat horizon anywhere), the flat-run condition rejects the ground truth: pass `--flat-run 0 --min-low-deg 0` to drop it and rely on `--near-flat` + `--min-peak`, then rank with `terrain.py fit`. If the skyline alone can't separate the top clusters (rms within a few px of each other), add the heading constraint from the line (view ≈ perpendicular to the line when the wires cross the frame nearly level): `terrain.py fit --perp-line lines.geojson`. When a lower near range stands in front of the far one, score both layers (`ridge --near/--near-clear`, `geometry.md` 7.4); the outermost skyline alone ranked look-alike ranges level with the right one. - **Set thresholds at about half the photo estimate**. At z10 one cell is about 150 m, peaks get flattened, and computed elevation angles come out low. In a real case, with thresholds set to the photo estimate, not a single point near the ground truth was kept; it came in only after loosening. Better too many than missing one; leave the count to the next ranking step. - How far the infrastructure is at the left edge, center and right edge of the frame is also an independent numeric constraint: use evenly spaced structures as a ruler (`geometry.md` section 3) to estimate the three distances, and pass them to `terrain.py fit` via `--line` / `--line-dist` for joint scoring. ## 5. Large rivers: identify the river → river section → line-to-point 1. **Three things to identify the river**: width (use houses, lanes, terraced fields on both banks as a ruler), color (sediment load), landforms on both banks (plain / high-mountain gorge / low, broken gullies). The three combined are more reliable than color alone (many rivers are yellow in flood season). 2. **Determining the orientation** needs a time: compute direction from the sun (`sky.md`); don't assume "the shaded slope faces north". 3. **Filter river sections by orientation + terrain on both banks**: list sections with the same orientation, the same width and the same landforms on both banks, and exclude them one by one. 4. **Line-to-point**: find discrete structures on the line in the image — bridges (when the bridge deck isn't clear, look for a long thin shadow on the water), dams, ferries, tunnel portals, interchanges, docks — and list all such structures on this stretch of river. v008: an 850 km stretch of a large river → 26 river-crossing structures → 7 → 2 → 1. ```bash uv run scripts/osm.py crossings --bbox <s,w,n,e> --line '["waterway"="river"]["name"="<river name>"]' --kind bridge,dam,ferry --out crossings.json ``` 5. **Go through the candidate table one by one**: for each point, ask only a few questions you can see directly in the photo (does the river bend within the view, is there a reservoir, are both banks mountains, is there a large settlement); go through all of them once, then compare in detail. **Criteria compare only the section within the photo's field of view** (v008 excluded a candidate using a bend outside the view and nearly filtered out the answer). 6. When a few remain, compare ridgelines and the roads on them with `terrain.py view` or Google Earth's tilted view. ## 6. Template comparison between cities When there are several candidate cities (v006: three candidate cities with similar layouts), write the large-scale structures in the photo as a template and compare city by city: - Water system: how many rivers, each one's orientation (combined with heading), confluence angle, whether there's a river island, river width, bridge positions, whether the far bank is urban. - Mountains: which side the mountain is on, how far from the urban area, whether there's a tower or statue on the summit. - Road network: positions of elevated roads, roundabouts and railways in the urban area. Features like river islands, whether rivers meet, and river width exclude at a glance; compare them first. Mind the satellite imagery's year (shorelines and river islands change with water levels and engineering works). ## Common mistakes - Treating a probability bet as a filter: "green taxis are most common in Sichuan" so you only search Sichuan; "the top 20 cities" (v002, v006). These can add to a score; they can't exclude. - Treating co-occurrence in two samples as a rule: "both places with this kind of pole are next to a railway → there's a railway near the photo too" (v002). - Stacking inferences on a false premise: "snowy mountains in the distance → must be in a certain province → search only that province's railways"; the premise wasn't verified, so everything after it is wrong. - Using an unverified interpretation as a hard filter: "the angle tower must be on the outer side of the crossing" — the true point was in the batch that got filtered out. Interpretations only rank; when nothing matches, first go back to the filtered-out ones. - Excluding because OSM has no result: missing lines are common in Chinese data. - Declaring failure when no candidate matches, without going back to check the filter conditions (hard rule 9). -
data-sources.md 12.7 KB
# Scripts, data sources and coordinate systems ## Script overview (`scripts/`) | Script | What it does | |---|--- | | `doctor.py` | Environment checks, browser launch, optional endpoint probes; English fixes and JSON output | | `exif.py` | GPS, capture time, equivalent focal length, camera heading | | `imgprep.py` | zoom (enlarge to read text) / edges (four edges, four corners) / variants (image-search variants) / grid (split into tiles) / `piers` (brightness profile along a given row to find pixel columns of evenly spaced structures; outputs a check image) | | `revimg.py` | Baidu image search + Yandex reverse image search; `--query` Chinese keyword search (Bing China, Baidu/Sogou Images) | | `geo.py` | Coordinate conversion, bearing and distance, camera geometry (`range --hfov a:b` distance range), `line` alignment line, `intersect` sight-line intersection, `frame` computes the frame and occlusion before excluding, `spacing` pixel columns of evenly spaced structures × known polyline → solve for camera position (optionally scored jointly with the skyline) | | `poi.py` | Place names, residential compound names, housing development names, shop names → candidate coordinates; `--region <cc>` picks the pack's sources (CN: 360 Maps + OSM Nominatim + Baidu suggestions), otherwise OSM; lists every same-name point | | `sun.py` | Sun position, shadow-length ratio, `locate` location band, `when` time, `street` street orientation, `facing` heading from lit faces, `dish` satellite dish | | `osm.py` | Overpass: find / near (co-occurrence) / crossings (line-to-point) / route (route corridor) / intersect (crossings of two kinds of lines; bends are only labeled, `--rank-near` ranks) / street-scan (street-view geometry template) / geom (export geometry) | | `tiles.py` | Satellite tile mosaic, `mark` plots points + overlays GeoJSON lines + field-of-view wedge, `sheet` numbered thumbnails of candidate points | | `baidu_pano.py` | Baidu panoramas: near / info / scan / render / sheet (`--headings` to look around from one point, `--road` `--spread`) / sample (street view sampling of candidate cities) | | `gsv.py` | Google Street View (outside China): near / render / sheet / `targets` (panoramas around each target point, rendered facing it, for `match.py --items`), no key, official coverage only | | `pose.py` | Solve camera position from multiple points: lat/lon, height, heading, pitch, roll, field of view + error radius + per-point check; `check` scores discrete candidate camera positions; `project` projects map points back onto the photo | | `terrain.py` | Elevation: view (synthesized mountain view; `--overlay` overlays the skyline on the photo, `--roll`) / profile (skyline) / elev / `ridge` reads ridgeline pixel points from the photo / `scan` filters a whole region along infrastructure lines for "flat nearby + mountain present" points and clusters them / `fit` batch skyline scoring of candidate camera positions (optional infrastructure-distance constraint; outputs overlays of the top N) | | `evidence.py` | Evidence image: satellite image + camera-position wedge + comparison panels | | `intake.py` | Steps 0–3 in one command: exif + edge crops + variants + OCR + Baidu/Yandex reverse image search in parallel; outputs intake.md (tiered vote count, possible place names) | | `ocr.py` | Reads text in the photo (Apple Vision, falls back to RapidOCR): full image + zoomed + tiles, merged; text read only after zooming is marked in `pass` | | `clues.py` | Lookup tables: country calling codes, driving side, overseas territories (`data/`); plates, area codes, admin hierarchy and other country tables from region packs (`--country`) | | `regions.py` | Region packs: `list`, `show <cc>` (lookups, services, tips, clue index with line numbers), `lint` (pack contract) | | `refresh.py` | Maintainers: re-fetch the global tables and the China pack's tables from their sources | | `board.py` | Candidate board: candidates, clues, evidence likelihood ratios, exclusions (require a computed file), ranking, scan cost, next step, pre-conclusion check, generates result.json fields | | `gazetteer.py` | Admin-division gazetteer: lists all subdivisions (with bbox), built-up area extents, scan pages | | `sat_scan.py` | CLIP zero-shot scoring and ranking of satellite grid cells/candidate points (sports fields, factory buildings, silos, dams…), top-N thumbnails + heatmap | | `match.py` | Ranks the photo against candidate ground-level images: DINOv2 global similarity + SIFT inlier re-ranking; candidates can be rendered on the fly from panorama ids | | `geo.py bearings` | Camera position → bearing, angular width and distance of each outline in a GeoJSON; use with `sun.py compass` to compute the bearing first, then identify the structure | ## Coordinate systems (must be kept apart in China) | Code | Name | Who uses it | |---|---|---| | wgs | WGS84 | GPS, photo EXIF, Google satellite imagery, OpenStreetMap, elevation tiles | | gcj | GCJ-02 | Amap (Gaode), Tencent, 360 Maps, Google's China road maps, **Google Earth's Chinese label layer in China** | | bd | BD-09 | Baidu Maps lat/lon | | bdmc | Baidu Mercator | `@x,y` in Baidu Maps URLs, the Baidu panorama API | In China the same point differs by several hundred meters between WGS84 and GCJ-02. Conversion: `scripts/geo.py convert --from X --to Y a b`. - Amap links use gcj: `https://uri.amap.com/marker?position=<lon>,<lat>`; Google links use wgs. - **Google Earth in China: the imagery is WGS84, the Chinese place-name labels are GCJ-02, and the two are offset by several hundred meters** (v005: a dock's Chinese label landed on the river surface). Place points and read coordinates from the imagery. - For coordinates read from Chinese map apps or websites, confirm the coordinate system before using them. ## Satellite imagery | Source | Notes | |---|---| | Google satellite tiles | `mt1.google.com/vt/lyrs=s`, WGS84, sharp in China. `tiles.py` default | | Esri World Imagery | Backup; older in some parts of China; has the Wayback historical archive | | Google Earth desktop | Historical imagery timeline, tilted 3D (manual use); 3D models in China are old, newly built supertall buildings are often missing | - The same place at different zoom levels may be imagery from different years and different tilt angles. - Zoom 17 is about 1.1 m/pixel, for areas; zoom 19 is about 0.28 m/pixel, for single buildings; zoom 7–9 as the base map for `sun.py locate --mosaic`. ## Street view and ground-level imagery ### Baidu panoramas (main source in China) The endpoints are all at `https://mapsv0.bdimg.com/`, need no key, and must be accessed directly: | Purpose | Parameters | |---|---| | Nearest panorama to a point | `?qt=qsdata&x=<bdmc x>&y=<bdmc y>` | | Panorama info (date, location, all points along the road, historical versions) | `?qt=sdata&sid=<panoid>` | | Render a perspective view by heading | `?qt=pr3d&panoid=<id>&heading=<compass angle>&pitch=<pitch>&fovy=<vertical FOV>&width=<≤1024>&height=<>` | - heading is a compass bearing, 0 = due north, clockwise; a width over 1024 returns 404. - Covers urban arterial roads and many roads inside industrial zones; almost nothing inside residential compounds. Captures are mostly from 2017–2019, so new buildings aren't visible. - Place search on map.baidu.com triggers a captcha; don't try to get around it; use `poi.py` for place names. ### Other | Source | Use | Known issues | |---|---|---| | Google Street View | Street view outside China, with historical dates; `gsv.py` | Almost none in China; user-uploaded panoramas are filtered because perspective rendering fails. `targets` uses the undocumented z17 coverage layer (`/maps/photometa/ac/v1`); failures are reported and not cached. The layer is incomplete: empty coverage is not evidence against a target; check with `near` | | Mapillary, KartaView | Crowdsourced street view, rural roads outside China | Almost none in China | | Tencent Street View | Backup in China | API not yet investigated | | Map POI photos, hotel/scenic-area photos online, tourist photos | Compare skylines and building shapes when there's no street view | Shooting angle can't be controlled | ## Search | Source | Good at | How to use | |---|---|---| | Baidu image search | Chinese web pages, Weibo, Baijiahao, e-commerce, scenic areas; gives "图中可能是…" ("the image may show…") | `revimg.py` | | Yandex Images | Buildings, street view, foreign content; gives tags and source sites | `revimg.py` | | Google Lens | Recognizing "what this is" (species, car models, statues, attractions), often stronger than Baidu and Yandex | Requests from a server's egress IP get challenged for verification; when you have a browser-control tool, use it in the user's browser; the AI Overview will confidently report a place name based on similar images | | Bing China, Baidu Images, Sogou Images | Chinese-keyword web and image search | `revimg.py --query`; Baidu web search pops up a verification challenge, not used | | Douyin, Xiaohongshu, Weibo | Influencer check-in spots, scenic areas' official accounts, same-city content | Web search or user assistance | | Development photo albums on real-estate sites (Anjuke, Fang.com, Loupan.com, etc.) | New housing developments, commercial complexes; albums include signboards | Web search the development name | | Travel review sites (Tripadvisor, Ctrip) | User photos of statues, parks, attractions | Web search | | Stock photo libraries (VCG, Getty, Alamy) | Captions carry exact place names and dates | Web search | | Local government and local media websites | Check the names and sizes of scenic areas, towers, statues | Web search | ## Place name → coordinates (China) | Source | Notes | |---|---| | 360 Maps search `restapi.map.so.com/newapi` | No key; good coverage of residential compounds, housing developments, shops and organizations, with address and district; coordinates are GCJ-02 (`poi.py` already converts to WGS84); without a city it returns a list of cities nationwide that have same-name results | | OpenStreetMap Nominatim | Named residential compounds, parks, roads; WGS84 | | Baidu Maps search suggestions `map.baidu.com/su` | No key; only "city + district + name", no coordinates | | Baidu Maps place search, Tencent and Amap APIs | Baidu needs a captcha, Tencent and Amap need keys; not used | ## Place name → coordinates (outside China) - `poi.py "<address or place name>" --sources osm --country <two-letter country code>` (Nominatim, WGS84). When a street address isn't found, drop the house number and search only the street + district name. ## Ground photos (when there's no street view) | Source | Notes | |---|---| | Images from news, encyclopedias, company websites, blogs | First find a name for the facility (OSM name, nearby place name + the local-language word for the facility type), then search; rural factory buildings and abandoned facilities often have only this kind of ground photo | | Wikimedia Commons search by coordinates | `commons.wikimedia.org/w/api.php?action=query&list=geosearch&gscoord=<lat>|<lon>&gsradius=10000&gsnamespace=6&format=json`, no key; remote areas often have only a few | | Mapillary | Wide coverage, but the API needs an OAuth token; the scripts don't use it | | KartaView | API needs no key; very little coverage | ## Thematic maps and structured data | Source | Use | Known issues | |---|---|---| | OpenStreetMap Overpass | Feature co-occurrence, line-to-point, route corridors, line crossings, street-view templates, points along a road, large buildings | `osm.py`; public servers are often busy or rate-limited (the script retries on mirrors; a result with a remark gets a warning that it may be incomplete); **ranges of hundreds of kilometers with a name regex (`[~"name"~...]`) often time out**; drop the regex or query by sub-area; in Chinese counties and townships, buildings and parking are basically empty, and rivers often have only centerlines | | OpenRailwayMap (openrailwaymap.org) | Railway class, single/double track, electrification, stations | Viewed manually on the web; same data as OSM | | OpenInfraMap (openinframap.org) | Power lines and voltage, substations | Viewed manually on the web; voltage may be untagged | | AWS Terrain Tiles (Terrarium) | Global elevation, about 30 m | `terrain.py`; details smaller than a hundred meters are unreliable | | City open data | Street trees (species, trunk diameter at breast height, location), etc. | Many foreign cities, few Chinese ones | ## Time and weather | Source | Use | |---|---| | `sun.py` (NOAA algorithm, within ≤0.02° of NREL SPA) | Sun position; replaces SunCalc | | Historical daily weather (temperature, sun/rain) | Check "frozen", "sunny"; with a date, exclude overcast or rainy areas | | Historical weather-satellite cloud imagery | Exclude large cloud areas on that day (clearly effective only with typhoons and fronts) | | Flightradar24, FlightAware | Flight history by registration; the paid tier can download KML/CSV tracks | -
geometry.md 37.1 KB
# Camera geometry: recovering bearing, distance and height from the frame Scripts: `scripts/geo.py` (`range` / `bearing` / `dest` / `fov` / `line` / `intersect` / `frame`), `scripts/pose.py` (solving the camera position from multiple points, scoring candidate camera positions), `scripts/terrain.py` (skyline rendering). All results are estimates. When you write a conclusion, write out the assumptions you used (lens zoom, object size) with it. ## 1. Determine the field of view first The main camera (35mm-equivalent about 24–26mm) is the most common phone lens, but **without EXIF, never lock the focal to it**. Distant mountains, compressed layers and a far object filling the frame are often shot at 2–3x or more. Any search that solves for focal must cover ultra-wide to telephoto (`terrain.py fit` does 13–135mm by default when the focal is assumed); a main-camera-only search simply cannot reach a 52mm photo, and its best fit lands somewhere wrong. | Equivalent focal length | 4:3 long-side FOV | 4:3 short-side FOV | |---|---|---| | 13mm (0.5x ultra-wide) | ≈107° | ≈88° | | 24mm | ≈71° | ≈57° | | 26mm (common 1x) | ≈67° | ≈53° | | 48–52mm (2x) | ≈38° | ≈29° | | 77mm (3x) | ≈26° | ≈19° | - In portrait orientation, the photo width corresponds to the short-side FOV; in landscape, to the long-side FOV. - Judging zoom: obvious stretching at the frame edges, nearby objects very large → ultra-wide; small size difference between near and far, background looks "pressed in" → telephoto. Use this to choose where to look first, not to narrow the search range. - Screenshots and cropped photos have a smaller FOV; use a conservative range. **Video screenshots are narrower**: phone video is 16:9; the frame width of a vertical video corresponds to the short side of 16:9, and stabilization crops off another margin. | Zoom | Landscape video horizontal FOV | Vertical video horizontal FOV | |---|---|---| | 0.5x | ≈95–106° | ≈65–73° | | 1x | ≈58–67° | ≈35–41° | | 2x | ≈32–37° | ≈18–21° | | 3x | ≈22–25° | ≈12–14° | | 5x | ≈14–16° | ≈8–9° | When you can't tell the zoom, don't guess a single value: `geo.py range --real <object size> --pixels <pixels> --image-width <width> --hfov 8:70` gives the whole distance range; search for candidates over that range. Then use section 11 to check whether "landmark X is not in the frame" can exclude anything. The zoom can be backed out: identify two or more landmarks with known positions; the angle spanned by their horizontal pixel spacing in the frame can be computed from the candidate camera position; comparing the two gives the focal length (with 4 or more points, use `pose.py` from section 10 directly, with hfov not fixed). Focal length (pixels): `f = (image width in px / 2) / tan(FOV / 2)` ## 2. Find the horizon **When shooting down from a tall building, shooting up, or holding the phone tilted, the horizon is not at the middle of the frame** (one calculation using that default got the ridge height difference wrong by 2–3×). In that case: - Estimate pitch from the convergence of vertical building edges: edges converging downward = shooting down, converging upward = shooting up; asymmetric tilt of the left and right building edges = roll. - When you can identify ≥4 points with known positions, use `pose.py` from section 10 to solve pitch, roll and height together. When the phone is held level, the horizon is at the vertical middle of the frame. To verify: - Vertical building edges are basically parallel, with no obvious upward convergence → phone is level - The row on which a distant object at the photographer's height lands (e.g., the roof of a distant building a few floors high, when the photographer is also a few floors up) is the horizon ## 3. Estimate distance from a known size `distance ≈ real size × f / size in pixels` Common reference sizes: | Object | Size | |---|---| | Residential floor height | 2.9–3.0 m | | Office/factory floor height | 3.3–4.5 m | | Car width / length | 1.8 m / 4.7 m; MPV length about 5 m | | Urban lane width | 3.5 m | | Standard parking space | 2.5 × 5.3 m | | Balcony railing baluster spacing | about 0.11 m | | Chinese high-speed and intercity rail simply supported box girder span (pier spacing) | mostly 32 m, also 24 m and 40 m; when you can count the piers, a row of piers is a ruler | | Supertall tower width | look it up; if you can't find it, measure the roof on satellite imagery | Estimate the same object two ways (e.g., building width and floor height). A large gap means the zoom or size assumption is wrong. ## 4. Recover the shooting direction from two landmarks (most useful) Conditions: the positions of two landmarks are known (measured on satellite imagery), and in the photo you can tell which is nearer/farther and which is left/right. 1. On satellite imagery, determine the direction of the line connecting the two landmarks. 2. For objects of equal height, the one whose top sits lower in the frame is farther. With a known width, use section 3 to compute each distance. 3. Only a few directions satisfy "near one on the left, far one on the right": looking obliquely from one side of the connecting line. Example: two towers lie east–west, near tower on the left, far tower on the right → the photographer is west-southwest or east-northeast; from due south/north the two towers are equally far; from the southeast/northwest the left/right near/far relation reverses. 4. The horizontal angle between the two towers in the frame, combined with their actual spacing, further gives the angle between the sight line and the connecting line. 5. Usually two candidate directions remain; use satellite imagery to see which side's building layout looks like the photo, and exclude the other. ## 5. Road direction - Find the street's vanishing point (the intersection of the extended eave lines of buildings on both sides and the road edges). - Horizontal pixel difference between a landmark and the vanishing point in the frame → angle: `angle = atan((x_landmark − x_center) / f) − atan((x_vanishing − x_center) / f)` - Road bearing = landmark bearing − angle. Look for a road with this direction on satellite imagery. ## 6. Camera height - A certain floor of a building in the frame lies on the horizon → the photographer is at the same height as that floor. - Or: the ratio of the pixel distances from an object's top edge and bottom edge to the horizon = part above the photographer : part below the photographer. Example: a glass curtain wall's top edge is 283 px above the horizon and its bottom edge 157 px below; the wall spans 4–20 m above ground → the photographer is about 10 m above ground. - Looking down at nearby objects on the ground: `depression angle = atan(pixel distance to the center line / f)`; with a known horizontal distance you can get the height. - Cross-check with at least two references and give a floor range (e.g., "floors 3–6"), not a single number. ## 7. Sight-line geometry: fixing the camera position without focal length or sizes This section is the main way humans beat the AI in the videos (v006, v010-1/2/3/7). Do it after identifying an anchor and before street view comparison. ### 7.1 Alignment line (extend the line through two points) When a near object and a far object in the frame are vertically aligned or one hides the other (a spire right above a house, a court's diagonal pointing straight at the lens), the camera position is on the extension of the line "far object → near object", beyond the near object. ```bash uv run scripts/geo.py line --near <near object lat,lon> --far <far object lat,lon> --range 50:2000 --step 100 --out line.json uv run scripts/tiles.py mark area.jpg --points line.json --out area_line.jpg ``` Then cut the camera position out of the line with a third constraint: riverbank, road, a tall-enough building, camera height. ### 7.2 Sight-line intersection (two lines fix a point) ```bash uv run scripts/geo.py intersect --align1 <near1>:<far1> --align2 <near2>:<far2> --sigma 1 uv run scripts/geo.py intersect --sight1 <landmark lat,lon>@<bearing to it from the camera position> --sight2 ... --sigma 2 ``` - Outputs the intersection, the angle between the two lines, and the error radius (the maximum distance the intersection moves when each line's bearing is off by ±sigma). - When the angle is <15°, the intersection is extremely sensitive to error; find another line at a large angle. - The error radius is the radius to write in the conclusion; don't make up decimal places. ### 7.3 Tangent line A ridge or cliff edge is exactly tangent in the frame to the edge of a nearby feature (field edge, riverbank line, eave) → draw the same tangent line on satellite imagery; the camera position is on that line (v010-2). ### 7.4 A skyline gives only one sight line Moving the camera position a few hundred meters forward or back along the sight line barely changes the outline of a distant ridge. So "the mountain shape matches" only says the camera position is near some sight line; **it can't fix a point, much less give meter-level precision** (v010-1: the AI reported ±7 m from the mountain outline alone and landed in the wrong town). ```bash uv run scripts/terrain.py view --at <candidate camera position> --heading <heading> --hfov 60 --range 25000 --out v.png --photo photo.jpg ``` Use it to screen candidate camera positions and fix the sight-line bearing; to fix a point, add another independent constraint (another near–far alignment, a road or riverbank, camera height). When rendering, the FOV must match the photo: portrait phone main camera horizontal FOV about 50°, landscape about 65°; for telephoto shrink it by the zoom (2x roughly halves it). - `--overlay` outputs an extra image that draws the synthetic skyline directly on the photo by pinhole projection; easier to match than stacking them top and bottom. When it doesn't match, adjust `--heading / --pitch / --hfov / --roll` first, before doubting the camera position. `--pitch` is positive looking up: horizon below the photo's middle → positive, above → negative. - When spiky false ridges appear close by (elevation sampling artifacts), set `--near` to 150–300 m. - When the ridges around a city are gentle, the skyline only helps you fix heading and FOV; it can't separate camera positions within a few hundred meters. **When there are many candidate camera positions, score them in batch, then look at overlays of the top few** (single source; one real case). Two steps: first `terrain.py ridge` reads the photo's ridge into a sequence of pixel points, then `terrain.py fit` places grids of camera positions around the candidate points and searches heading × focal length × horizon row. ```bash # 1) Read the photo ridge: per column, find the brightness drop from sky to mountain uv run ${CLAUDE_SKILL_DIR}/scripts/terrain.py ridge photo.jpg [--x0 X0 --x1 X1 | --points "x,y;x,y;..."] --out ridge.json \ [--step 20] [--flat x0:x1] [--hrow ROW] [--f0 px] [--f35 26.0] [--ymin 0] [--ymax IMAGE_HEIGHT] \ [--near "x,y;x,y;..."] [--near-clear x0:x1@ROW ...] \ [--drop 30] [--k 4] [--hold 12] [--halfw 1] [--png check.png] # 2) Batch scoring (fine search = the same command with --radius 800 --grid 100 --zoom 13 --az-step 0.25 --nsamp 400) uv run ${CLAUDE_SKILL_DIR}/scripts/terrain.py fit (--hits hits.json | --at lat,lon) --ridge ridge.json --out fit.json \ [--select 11,87,...] [--radius 2000] [--grid 250] [--zoom 11] [--focal-scales auto | --f35-range LO:HI] [--az-step 0.5] \ [--near 150] [--range 15000] [--nsamp 260] [--eye 1.6] [--cam-flat 8] [--cam-flat-radius 300] \ [--min-peak 6] [--cc-max 0.7] [--roll-max 1.0] [--flat-clear 1.0] [--flat-w 1.5] [--flat-step 20] \ [--line lines.geojson --line-dist L0-L1:C0-C1:R0-R1 [--line-win -27:-21,-3:3,18:27] [--line-scale 200,200,300] \ [--line-order none|asc|desc] [--line-w 0.3] [--line-min 150] [--line-sample 50] [--line-reach 3000] [--skip-tag electrified=no ...]] \ [--near-dist 3000,5000,8000,12000] [--near-clear-w 1.5] [--perp-line lines.geojson [--perp-tol 35] [--perp-reach 1500]] \ [--top 20] [--keep 5000] [--sheet top.jpg] [--overlay|--photo photo.jpg] [--sheet-cols 4] [--sheet-width 360] ``` - `ridge`: `--x0/--x1` is the column range of the ridge, `--step` the spacing between sampled points, `--flat x0:x1` the column range of the flat horizon (the stretch with no mountains), `--hrow` the horizon row (if omitted, estimated from the middle of the frame). `--drop/--k/--hold/--halfw` are the thresholds for finding the brightness drop (drop size, multiple, consecutive rows, horizontal smoothing half-width). Outputs `{size, ridge:[[x,y]...], flat:[x0,x1], hrow, f0, f0_source}`; when the photo has no EXIF focal length, `f0` is estimated from the `--f35` equivalent focal length and the image dimensions and marked `f0_source=assumed`. `--png` outputs a check image. `--points` replaces detection with hand-picked skyline points: use it when cloud sits on the peaks or snow meets a pale sky, because detection then follows the cloud edge and the residual never comes down. - `fit`: candidates come from `--hits` (the scan results of `corridors.md` 4.3; `--select` takes only the given cluster numbers) or a single point via `--at`. Around each candidate point, camera positions are placed on a `--grid`-meter grid within `--radius` meters; heading is scanned in `--az-step` steps, focal length is tried at the `--focal-scales` multipliers, and the horizon row is searched too — **don't hard-code "portrait 50°, horizon at the middle"**. The camera position itself must be flat close by (relief of at most `--cam-flat` m within `--cam-flat-radius` m), and the mountains must be high enough (`--min-peak`). Score = ridge elevation-angle RMS + a penalty for the flat-horizon stretch being blocked by mountains (`--flat-clear` tolerance, `--flat-w` weight). `rms_px` in the output is the RMS converted to pixels; compare it with the point-picking error of the photo ridge: when the top few all have `rms_px` about as large as the picking error (a few to a dozen or so pixels), the skyline can't separate these camera positions and you need a second constraint. Focal search is automatic (`--focal-scales auto`): ×0.9/1/1.12 around an EXIF or `--f0` focal (pass `--focal-scales 1` to pin it), a 13–135mm-equivalent ladder (~18% steps) when ridge.json's focal is assumed. On the ladder, scores are compared in pixels (`--focal-metric auto`): in degrees the same pixel mismatch shrinks as the focal grows, and on a real photo every candidate ran to the longest rung. `f35` in each record is the equivalent focal in mm. - **A focal on the edge of the range is a signal, not noise.** When the leading results sit on one end of the ladder, fit prints a WARNING and writes `focal_edge`. Most often the photo is more zoomed (or wider) than the range: rerun with `--f35-range` extended past that end (the warning suggests one) before trusting the ranking, heading or focal. A search that keeps everything on the long end is telling you the photo was zoomed; ignoring that and ranking anyway sends you to a place where a shorter lens happens to fit. In the real case: with a 20–40mm range all 10 leading results sat on the long end and the true area ranked 97th; with 13–135mm the edge cleared, the focal settled inside the range and the true area ranked 1st of about 1,800. Only when the focal runs to the new end again, or the focal is known from EXIF and still hits ×0.9/×1.12, is it absorbing another error: check cloud on the ridge points (`ridge --png`), `hrow`, and whether the place is wrong. - **Distant ranges**: the default `--range 15000` only sees terrain within 15 km. For a range 20–40 km away use `--range 45000`; z10 flattens peaks, so lower `--min-peak` to about half the photo estimate (corridors.md 4.3). - **Two-layer photos** (a lower near range in front of a higher far range, and the near range ends mid-frame so the far range's foot or plain shows beyond it). `fit` on the outermost skyline alone can match the far outline at a wrong heading with a wrong focal; in one real case the far-only fit at the true camera position chose a heading 50° off and a near-maximal focal, while the two-layer fit got the heading within 0.5°. In `ridge`, add `--near` (points on the near range's top edge, where it stands in front of the far range) and `--near-clear x0:x1@ROW` (columns past the near range's end, where nothing near may rise above ROW, usually the far range's foot). `fit` then computes a near layer = highest terrain within D1 m (D1 tried from `--near-dist`), scores the near points with the far ones (one `cc` and roll for both), and adds `--near-clear-w` × `nclearpen`. The record gains `D1` (how far the near range reaches) and `rms_near_px`; the sheet draws the near layer in orange. Pick near points by hand and check them on `ridge --png`: the near/far boundary is where detection fails. - **Wires crossing the frame almost level** (high-voltage lines overhead): `--perp-line lines.geojson` keeps only headings within `--perp-tol` (default 35°) of perpendicular to the nearest line; camera positions more than `--perp-reach` m from any line are skipped. In the real case this, plus the near layer, was what separated the right region from look-alike ranges elsewhere. - **Solve roll too** (`--roll-max`, default 1.0°; 0 gives the old behavior): a 1° handheld tilt makes the ridge at the two ends of the frame differ by a dozen or so pixels; if roll isn't solved, the ground truth gets crowded into the same RMS tier as a pile of wrong candidates. For each heading, a least-squares fit of "terrain elevation angle − photo elevation angle" against horizontal azimuth gives the horizon offset `cc` as the intercept and tan(roll) as the slope; the result is written into the camera-position record's `roll`. Keep the cap tight: in both synthetic tests and real photos ±1° works better than ±2.5° (when loosened, wrong candidates can also absorb noise through tilt, and the gap between the ground truth and second place is flattened). A solved `roll` that hits the cap means this degree of freedom is compensating for other errors (wrong camera position, wrong focal length); don't treat it as real tilt in the shot. - The facility-distance constraint is optional: `--line` gives the line data, `--line-dist` gives distance ranges (meters) at three places, left/center/right, `--line-win` the frame bearing windows for those three places (degrees, relative to heading), and `--line-order` requires the three distances to be monotonic (asc/desc). Falling outside a window is penalized by `--line-scale` and `--line-w`. - Output `fit.json`: `{params, n_clusters, n_cams, n_skipped:{not_flat, no_peak, no_line, dup}, clusters:[{hit, name, hit_ll, n, max_ang, n_cams, rank, best}...], cams:[{hit, name, hit_ll, cam:[lat,lon], d, brg, g, H, fs, f, cc, roll, rms, rms_px, flatpen, score, (dL/dC/dR/line_pen), total}...]}`; `--sheet` outputs thumbnails of the top N; with `--photo` they are the photo overlaid with the synthetic skyline (the red line is drawn with the `roll` solved for that camera position; otherwise the two ends would be off by a dozen or so pixels). - `ridge` / `fit` both use `terrain.py`'s `--cache` parameter (default `.geo-cache/dem`). - When spiky false ridges appear close by (elevation sampling artifacts), set `--near` to 150–300 m. - **Fine search (z13) is not guaranteed to be more accurate than coarse search**: in reruns, fine search on the same chain ended up farther from the ground truth than coarse search. The z13 flatness filter removes points near the ground truth, and the skyline itself gives only one sight line. Fine search exists to give 7.7's evenly spaced structures a reliable `--center`, not to push the error down by itself. - `H` and `f` in the `fit` output have a systematic bias that comes from `hrow` in `ridge.json`: in the same coarse search, hrow set by hand to 935 gave H 80.5 / f 1436, while the auto-estimated 909 gave H 79.0 / f 1282. Cluster ranking is unaffected (the ground-truth cluster ranked 1st both times); if you want to make claims from heading and focal length, first pin down the blue line on `ridge --png`. - **Skyline alone can't separate them**: in a real case with 171 candidates the ground truth ranked 2nd, but the top 20 all had RMS crowded into 0.10–0.20°. Rank together with a second numeric constraint (e.g., how far the facility is from the left, center and right of the frame, `corridors.md` 4.3). - The stretch of ridge hidden by the foreground (people, cars, trees, nearby objects) doesn't enter the score, yet where the mountain foot starts to rise is often what best separates candidates. **Always `--overlay` the top 3–5 and look**: in a real case, the candidate ranked 1st overall had its ridge bulge in the occluded stretch and its mountain foot didn't match; the 2nd was the ground truth. ### 7.6 A nearby cable close to vertical in the frame If a cable, wire or stay very close to the lens looks almost vertical in the frame, the camera position is near the vertical plane of that line (looking from directly below or straight from the side). The bearing difference Δ (degrees) by which the line departs from vertical and spans horizontally in the frame, and the distance s from the line to the camera position, satisfy: lateral offset d ≈ s · tan(Δ). Get the line's position with `osm.py find '["aerialway"]'` or `'["power"="line"]'`. In a blind test, this alone once brought the lateral error down to a dozen or so meters. ### 7.5 Verify bearings with a top-down sketch Draw "camera position → foreground → midground → background → sun" as a top-down sketch with bearings; the candidate point must reproduce the same left/right, near/far and heading on the map. Rules in `verify.md` section 3. A sunset only says the lens faces west, not that the coast faces west (v010-7). ### 7.7 Evenly spaced structures: distance to the line and oblique angle When a row of evenly spaced things in the frame (viaduct piers, utility poles, streetlights, guardrail posts) lies on a line known on the map, you can solve for how far the camera position is from that line and at how many degrees it looks across it obliquely. Single source (one real case). 1. `imgprep.py piers` reads the pixel column of each structure (it takes a brightness profile along the rows where the structures are and finds peaks); ≥6 of them, and it's fine if the foreground breaks them into several segments. ```bash uv run ${CLAUDE_SKILL_DIR}/scripts/imgprep.py piers <image> --rows R0:R1 --out cols.json \ [--cols X0:X1] [--min-gap 20] [--min-prominence 12] [--baseline 61] [--polarity auto|bright|dark] [--sheet piers.jpg] ``` `--rows` is the row range for the profile (the rows the structures are in), `--cols` limits the column range, `--min-gap` is the minimum spacing in pixels between adjacent peaks, `--min-prominence` the minimum peak prominence, `--baseline` the sliding-window width (odd) used to estimate the background, and `--polarity` says whether the structures are brighter or darker than the background. Outputs `{image, size, rows:[r0,r1], cols:[x0,x1] (the column search range, not the structure columns), polarity, params, count, piers:[{col, prominence, dev, level}...]}`; the structure columns are in `piers`, and `geo.py spacing --cols @cols.json` reads that field directly. **You must eyeball the check image from `--sheet`**: bright patches of grass and the edges of foreground pipes produce false peaks; delete them before the next step. 2. `geo.py spacing`: for each candidate camera position, heading and focal length, convert the pixel columns into bearing rays and intersect them with the OSM polyline to get each structure's along-line distance; at the correct camera position the differences between adjacent along-line distances are constant and equal the standard spacing. ```bash uv run ${CLAUDE_SKILL_DIR}/scripts/geo.py spacing --cols '39,133,219,296,369;745,788,...,1148' \ --line rail.geojson [--line-name <line name>] [--line-index 0] --span 32 --center lat,lon \ [--radius 1500] [--grid 50] [--headings 0:360] [--heading-step 0.25] \ --focals 1200,1281,1350,1430,1500|1200:1500[:step] [--focal-step 50] [--cx 640] \ [--min-dist 100] [--pier-max 0.08] [--mono-penalty 1.0] [--top 25] [--progress 10] [--out spacing.json] \ [--ridge ridge.json] [--hrow 935] [--flat 0:280] [--flat-step 20] [--flat-margin 1.0] [--flat-weight 1.5] \ [--ridge-weight/--pier-weight 2.0] [--cc-max 0.8] [--eye 1.6] [--dem-zoom 13] [--dem-range 18000] \ [--sky-range 16000] [--sky-near 100] [--sky-samples 400] [--az-step 0.1] [--cache .geo-cache/dem] ``` - `--cols` are pixel columns; when the foreground breaks them into segments, group them with `;` (adjacent along-line differences are compared only within a group); at least 3. `--cx` is the center column of the frame. - When `--line` contains several lines, pick one with `--line-name` or `--line-index` (a name that doesn't exist lists the available names). `--span` is the standard spacing (meters). - Camera positions are searched on a `--grid`-meter grid within `--radius` meters of `--center`; heading is scanned by `--headings a:b` + `--heading-step`, and focal length is tried from the `--focals` list or `low:high[:step]`. Score = spacing dispersion (CV) + |log(mean spacing / standard spacing)|; `--pier-max` is the cutoff for inclusion, and `--mono-penalty` penalizes solutions whose along-line distances aren't monotonic. - Passing `--ridge` scores jointly with the 7.4 skyline (DEM fetched per `--dem-zoom`/`--dem-range`, weight `--ridge-weight`, flat-horizon stretch via `--flat*`). Outputs `{line, params, n_fit, best, candidates:[{tot, rms, pier, cam:[lat,lon], f, H, cc, span_m, d_first, d_last}...]}`. - With this alone, the solution is a band along the line (spread over about 800 m in a real case); scoring jointly with the skyline narrows it to about 300 m. Suited to fixing the camera position at the end, not to choosing a city. - The standard spacing is an assumption: a solved mean spacing equal to the standard value is forced by the scoring and doesn't count as verification; the real signal is low dispersion (CV 2–7% in a real case). When unsure of the spacing, run once with each of a few standard values. ## 8. Estimate object height Back-computing the time from the sun and estimating distance by proportion both need object height. Cross-check with at least two rulers: | Ruler | Size | |---|---| | Adult | about 1.7 m (posture and shoes add a few cm of error) | | Interior door / entrance door | about 2.0–2.1 m | | Residential floor height | 2.9–3.0 m; commercial buildings 4–5 m; factories 3.3–4.5 m | | Standard shipping container | 2.59 m; high cube 2.90 m | | Mounting height of traffic signs and signals | look up that country's standard; don't guess | When you have the building name, look up its floor count and floor height directly; that is more reliable than estimating from pedestrian proportions. ## 9. Screen buildings by camera height - Can look down on the roofs of surrounding 20–30-story residential towers → the camera position is in the city's tallest tier of buildings, often only a few candidates; not seeing the city's tallest building in the frame may be precisely because the photographer is standing on it (v006). - In a tourist's photo, a steady elevated downward shot from a building is most likely from their hotel (v011): within the candidate area, look first at hotels with parking lots. - Estimate the floor by cross-checking two references (section 6); give a range. ## 10. Solving the camera position from multiple points (pose.py) Window views, downward shots from tall buildings, views across a river: as long as you can identify ≥4 points on satellite imagery (bridgeheads, spires, building corners, intersections, sports-field corners), you can solve the camera position's lat/lon, height, heading, pitch, roll and FOV in one go. It uses height information that a two-sight-line intersection doesn't, and can compute the floor directly. ```bash # spec.json: {"frame":"upright", "image_size":[W,H], "points":[{"name":…,"px":[x,y],"ll":[lat,lon],"h":<elevation ASL>}...], # "init":{"at":[lat,lon],"height":<elevation ASL>,"heading":…,"pitch":…,"hfov":65}, "fix":["hfov"]} uv run scripts/pose.py solve spec.json --photo photo.jpg --out pose_check.jpg --search-radius 500 uv run scripts/pose.py project --pose pose.json --points river_bank.json --photo photo.jpg --out bank_check.jpg ``` - In this template, `image_size` and `px` use the whole photo after EXIF orientation is applied, matching `imgprep.py` and `ocr.py`. Old specs without `frame` retain the stored-pixel frame when solved again; the saved pose carries the frame through to `project`. - The heights of the points and of the camera position must use the same datum; always use elevation above sea level: ground points via `terrain.py elev`, rooftop = ground elevation + building height. - Spread the points across different directions and distances in the frame; if they all lie on one line the solution is unstable (the script warns). - Check `rms_px` in the output (reprojection error; >15 px means a point is misidentified or the height datums are inconsistent) and `radius_m` (95% error radius; use it directly as the radius in the conclusion). - **`--pt-sigma` must be set to match reality** (default 5 m): how many meters of error the control points you clicked on satellite imagery carry. Sharp-edged ones like building corners and bridgeheads 3–5 m; a tower in a cluster of trees or a round building whose center you can only estimate 10–15 m. Control-point coordinate error doesn't propagate into the reprojection residual (if all points shift the same way, the camera shifts with them and the residual stays the same), so it can only be propagated by the `--pt-sigma` Monte Carlo; you can't see it from rms alone. Set it too small and the reported radius is too small: in synthetic tests with a true 15 m but 5 m entered, coverage dropped from 95% to 53%. - `radius_px_only_m` in the output is the old measure (3σ of pixel noise only), kept for comparison; **don't use it as the conclusion's radius**: with coordinate noise ≥5 m it covers the ground truth only about 70% of the time. - After solving, use `project` to project riverbanks, roads and building corners back onto the photo and check them item by item; this is an independent second verification step. - Check on synthetic data: 8 points, 1.5 px noise, position error about 30 m (600 m height, points several km away); heading and pitch error <0.1°. - **Works for level shots too**: standing on the ground shooting distant towers, bridges or piers, add `height` to `fix` and give the eye height as `init.height` (same datum as the points). Accuracy depends on how widely the points spread in bearing: in synthetic tests (4 px pixel noise, 5–15 m coordinate noise) with the points all crowded within 10°, the median error is about 50 m and bad ones reach 200 m; spread to 40°, about 25 m. When they're crowded, report the conclusion's radius from `radius_m`; don't shrink it because "it matches". - **The main source of error is misidentified points, not the solver**: one misidentified point (wrong feature, a courtyard outline taken as a building corner, a tower of the same kind on the map that isn't the one in the frame) can pull the camera position off by tens of meters to over a hundred. `leave_one_out` in the `solve` output re-solves with each point removed in turn: `pred_err_px` is how far off this point is when predicted from the other points; `suspect` flags one only when "the remaining points fit clearly better without it" (the threshold is conservative; synthetic tests: downward shots with 7 points, false alarms about 1/20, detection about 12/20; level shots with 6 points unstable, detection 6–17/20; not possible when there is no redundancy after removing a point); `most_improved` lists "which point's removal improves things most" whether or not it passes the threshold — in a real case the mislabeled point was right there but didn't pass the threshold. Nothing flagged doesn't mean no point is misidentified. When several candidate camera positions all make sense and you need to pick one (a viewing platform marked on the map vs another stretch of shore on satellite imagery, several buildings on the same alignment line), use `check`: ```bash # cands.json: {"candA": [lat, lon], "candB": [lat, lon, <eye elevation ASL>]}; without a height, init.height is used uv run scripts/pose.py check spec.json --cands cands.json ``` - Each candidate camera position is held fixed; only heading, pitch, roll (and the focal length if not fixed) are solved, and reprojection errors are compared; then it re-scores with each point removed in turn, and `robust_delta_chi2` takes the minimum over all removals. **Only `robust_delta_chi2` > 9 counts as not matching the photo**, and then it can be the file for `board.py exclude --computed`; looking only at `delta_chi2` over all points can be flipped by one misidentified point (synthetic tests: with one misidentified point mixed in, the ground truth ranked first dropped from 15/15 to 1/15). - Measured on synthetic tests (90 runs, including ones with a misidentified point mixed in): the true camera position was never robustly judged a mismatch; with all points correct, decoys 120 m away were excluded 10–15/15, and 60 m away 6–8/15. - **It is for removing clear mismatches, not for picking out the ground truth**: in 160 synthetic tests it wrongly eliminated the ground truth only once (excluding by `solve`'s camera position + radius would wrongly eliminate it 35 times, so exclude with `check`, not with the radius); but in scenarios where `solve` itself breaks down (points all crowded within 10–12°, coarse point marking), it ranks the ground truth first only about half the time. Ranking first isn't final; still go back to street view/satellite imagery and check invariant features. - One more thing: few points isn't fatal (4 well-spread points still solve to a dozen or so meters); **what's fatal is all the points crowded in one bearing** — better to use fewer points and look for them on both sides. - When two candidates differ only on one point (`robust_by` is the same "drop X"), the geometry can't separate them; first go back and check X. If they still can't be separated, rely on the scene (is the foreground an open lawn, can you see water), and state in the conclusion that the scene decided it. ## 11. Compute the view before excluding: out of frame, occluded or too small ≠ absent Before you use "X is not in the frame" to exclude a camera position or a whole direction, you must compute it first. Common mistakes: a landmark sits right at the frame edge, so you assume "that famous building next to it would be visible if it were there"; imagining how wide a video screenshot's frame is using the main camera's FOV; a distant tower is only a few pixels, and you say "not seen". ```bash # Identified landmark A is at x=1000 in the frame (width 1080), FOV unknown; check whether B and C should appear from the candidate camera position uv run scripts/geo.py frame --at <camera position lat,lon> --anchor A:<lat,lon>:px=1000:h=<height>:w=<width> \ --width 1080 --hfov 8:70 --pt B:<lat,lon>:h=<height>:w=<width> --pt C:<lat,lon>:h=<height>:w=<width> --cam-h <camera height> # Place candidate camera positions in rings around A (radius 1–13 km, every 15°) and compute in one go which directions "no B" can exclude uv run scripts/geo.py frame --ring <A's lat,lon>:1000:13000:1000:15 --anchor A:<lat,lon>:px=1000:h=<height>:w=<width> \ --width 1080 --hfov 8:70 --pt B:... --out ring.json uv run scripts/tiles.py mark area.jpg --points ring_keep.json --geojson ... --out ring_keep.jpg ``` - `--anchor` makes the heading change with the FOV: the narrower the FOV, the narrower the frame, and the more easily landmarks next to A fall out of frame. - Each landmark gets a verdict: "in frame over the whole field-of-view range" / "in frame at field of view a–b°, out of frame otherwise" / "not in frame over the whole field-of-view range"; plus "may be blocked" (the height and width of a nearer landmark can block a farther one) and "may not be visible" when it is too small (`--min-px`, default 12 px). - **Only a landmark that is "in frame over the whole field-of-view range, large enough and not blocked" can exclude this camera position by its absence**. Nearby buildings the script doesn't know about also occlude; a direction with tall buildings in the foreground still can't be excluded. - Look up the landmarks' h and w or measure them on satellite imagery; h and `--cam-h` use the same datum. ## Common sources of error - Computing a 2x or 3x photo as 1x underestimates distance by half to two-thirds. - On satellite imagery the roof of a tall building is offset from its base, so you measure the wrong point. - When shooting through window glass, reflections and window frames are easily taken as scene content. - Declaring building level with only one alignment line (v006); reporting meter-level precision from the skyline outline alone (v010-1). - Treating "out of frame", "hidden by a nearby building" or "so far away it's only a few pixels" as "not there", and excluding the correct direction (section 11). - Computing video screenshots with the FOV of a phone photo: vertical video at 1x is only about 40°; add zoom and distances can be off several-fold. - In SunCalc-style tools, pinning the shadow tip at the base of the wall when what actually casts it is the eave or canopy edge; a few meters off shifts the time by several minutes (v007). -
observe.md 8.3 KB
# Image-reading checklist Used in step 1. Don't guess a location until the checklist is written. Number every item, write the original text or the shape, and mark anything you can't make out as "illegible". Ordered top to bottom by "how much area it can exclude": one clue higher up outweighs twenty below it. ## 0. First scan the whole frame ```bash uv run scripts/imgprep.py edges photo.jpg --out-dir edges/ # one zoomed crop per edge and per corner uv run scripts/imgprep.py zoom photo.jpg --box x0,y0,x1,y1 --scale 4 --out z1.png ``` - Go through the four corners, the bottom edge, and the top edge one by one: cables, boat bows, car window frames, aircraft wings, fingers, watermarks, screenshot UI. - Zoom into every distant sign and every vehicle once. The eye is pulled first to the most prominent landmark; small street signs are often more useful. ## 1. Shooting conditions (decide which branch to take) | Item | What to record | Used for | |---|---|---| | Shooting platform | ground / upper floor of a building / in a vehicle / on a boat or pontoon / cable car / train / airplane / drone | boat bow → at a dock or the waterside; car window → on a road, record the direction of travel; aircraft wing → aerial branch. **Tell apart top-down from a building vs an expressway service area vs inside a vehicle on an elevated road**: from a building the camera position is steady, you can look down on car roofs and building roofs, and window frames or sills are common; at a service area look at the orientation of rows of parking spaces, slope-protection retaining walls, trash bins, and gas station facilities; inside a vehicle there are window frames, windshield reflections, guardrails rushing past. Get the platform wrong and you pick the wrong co-occurrence conditions later | | Camera height | eye level or looking down; how many stories' rooftops you can look down on | filter buildings by height; test claims like "shot from the road" | | Focal length | EXIF equivalent focal length; if absent, judge from distortion and near/far compression | field of view, distance estimates | | Image processing | rephotographed (reflections, print edges, perspective distortion), screenshot, cropped, mirrored (reversed text, driver's seat on the unexpected side; with no text, look at hand gestures and clocks; reposted videos are often flipped horizontally); **video screenshot** (vertical 9:16, platform watermark, subtitle bar) with unknown zoom | correct before searching; flip mirrored images back before reading; compute the field of view of a video screenshot as a range (`geometry.md` section 1) | | Time | watermark, EXIF, shadow length and direction, daylight, season | sky branch; phenology judgments | ## 2. Layer 1: text, phone numbers, language - Shop signs, street-name signs, organization names, construction notices, public notices: copy the original text and search the original, not a translation. - **Issuing authority at the bottom of signs** (〇〇交警支队 traffic police detachment, 〇〇城管 urban management, 〇〇警察署 police station, 〇〇土木事務所 civil engineering office): often gives the district (county) directly. - **Destinations and terrain words mentioned in notices** ("no way through to the coast ahead", "to such-and-such scenic area"). - **Phone numbers**: record the area-code part and the digit grouping (first work out how many digits the area code has). - Language and script: simplified/traditional characters, dialect characters; for foreign text, identify the writing system before reading the characters. - **Official multilingual signs**: two languages on one official sign → an officially multilingual area (not "near a country that speaks that language"). - Chain brands: note sub-brands and business lines (only sub-brands with few stores have locating value). ## 3. Layer 2: vehicles and roads - **Plates**: characters (province/city code) → if unreadable, background color, color bands, graphics, border → failing that, aspect ratio (Europe long and narrow, North America about 2:1). Note where the vehicle is parked (plates in a parking lot count only as weak). - **Driving side**: which side traffic keeps to, which side the driver's seat is on, which way cars parked at the roadside face. - **Public transport**: bus operator abbreviation, route number, rear ads; taxi color; bus roof color (clearest in top-down shots). - Road marking color and style, guardrails, curb paint (alternating red and white, etc.), streetlight design and color, gutters, whether there is a grass strip between sidewalk and roadway. - Overhead gantry direction signs: when the text is illegible, count the destination lines and note the background color. ## 4. Layer 3: infrastructure specs (can be looked up in data) - **Railways**: catenary or not; masts on one side (usually single-track) or on both sides or with portal frames (double-track); how many parallel tracks at a level crossing; viaduct or embankment; railway company name on crossing signs. - **Power lines**: tower type (single tubular steel pole / angle-steel lattice tower); how many conductors per bundle per phase (common in China: 4 per bundle = 500 kV); angle relative to railways, rivers, roads. - **Special transport facilities**: cross-river cableways, road/rail-transit bridges, light rail through a building, high-speed rail bridges, long urban escalators, cable cars. - **Classify cables at the frame edges**: high-voltage lines (tall towers, insulators, separate phases, nearly horizontal over crossing spans) / passenger cableways (two or three closely parallel track ropes + a haul rope, steeply slanting and sagging) / cable-stayed bridge stays (fanning from the tower top) / ordinary wires (thin, messy, hung on utility poles). - Solar water heaters, density of AC outdoor units, security window grilles, rooftop water tanks. ## 5. Layer 4: sky - Shadows: direction (relative to the frame, relative to the road), ratio of length to object height, whether they are parallel to each other. - Satellite TV dishes: heading, size (small Hu Hu Tong dish / large dish). - Lit face: which face of a building is bright. - The sun itself, sunrise and sunset, backlit silhouettes. Details in `sky.md`. Without a time of day, shadow direction is only a weak constraint. ## 6. Layer 5: architecture and vegetation - Roof color and material (red terracotta / gray tile / flat roof / blue sheet metal), facade material (tile color, paint, stone). - For building elements, check the basic shape before using style words: round or pointed arches, number of window panes, columns. - Skyline: whether there are office clusters or supertall towers (weak: judges the city's tier). - Vegetation: evergreen or deciduous, flowering stage (first bloom / full bloom / petal fall), palms, bamboo groves, rice paddy color. **It must be paired with the month of capture to mean anything.** - Fog, cloud, snow, ice: only weak supporting evidence. ## 7. Layer 6: terrain and water - Plain or mountains; how far beyond the far bank the mountains rise; whether buildings are stacked up the slopes. - Rivers: width (use houses, lanes, terraces as a ruler), water color (sediment load), landforms on both banks, river islands, confluences, bridge positions. - Distant mountain outlines; towers, statues, pylons on peaks (skyline fingerprint). - Coastlines; mountain silhouettes across a bay. ## 8. Negative clues Write down what is absent: no utility poles, no snow, no palms, no motorcycles, no Chinese-character signs, no office towers. Negative clues can exclude regions just like positive ones. **Negative clues depend on visibility**: not seeing mountains or a river in the distance in haze, backlight, or dusk doesn't mean they aren't there; to use one, first compute from the distance whether it should be visible (`terrain.py view`, `geo.py frame`); otherwise use it only for ranking. ## Rules for writing the checklist - For anything unclear, describe only its shape, color, and position ("a light-colored patch with clean edges on the ridge"); **don't rush to name it**. A wrong name gets it discarded as something ordinary. - If you can't count it, write a broad word ("polygon"), not a wrong exact number. - Text that is only "read" after zooming in to the level of compression noise is a hypothesis. - When two clues conflict, write it down: it may be mirroring, an imported vehicle, a border area, a composite image. -
search.md 15 KB
# Search: reverse image search, keyword image search, social media search Use at step 3, in parallel with lookup-table clues. In 5 of the 20 puzzles in the videos this step was the breakthrough, yet it costs the least. ## 1. Reverse image search ```bash # Make variants first: tight crop of a distinctive object / horizontal flip / grayscale enhancement / color-cast removal; rectify rephotographed material first uv run scripts/imgprep.py variants photo.jpg --box 300,120,900,760 --out-dir v/ uv run scripts/imgprep.py variants photo.jpg --persp 312,140,880,95,905,770,290,720 --box ... --out-dir v/ # Baidu image search + Yandex; each image gets a result screenshot and JSON uv run scripts/revimg.py photo.jpg v/*.jpg --out-dir rev/ ``` **Always open the result screenshots and look**: extracted text is only an aid; the scenes in similar images and the titles of source posts matter more. Baidu's similar images are also saved as a numbered contact sheet `<name>_baidu_similar.jpg` (the top-left cell is the query image; number i corresponds to the source page of JSON `similar[i]`). Look at it first: for near-duplicate photos of the same object or scene, the source page (Dianping, Douyin, Xiaohongshu) often gives the shop name or location tag directly. **How to use place-name labels**: Baidu image search's "图中可能是…" ("the image may show…"), Yandex labels and the Lens AI overview are all candidates; count votes separately by level: - City level: count which city each crop and each engine named. The same city named by two different crops is more credible than "two contradictory specific places"; specific places that don't agree don't cancel the city they jointly point to. - Place level: when a label is the name of a residential compound, housing development, hotel or organization, get its coordinates with `poi.py` and check; this often gets you to the area in one step: ```bash uv run scripts/poi.py "<compound name>" --city <city> --region <cc> --out pois.json # every same-name point in the city (sources from the region pack, WGS84) uv run scripts/poi.py "<compound name>" --region <cc> # no city: which cities in the country have a same-name point uv run scripts/tiles.py sheet --points pois.json --zoom 18 --out pois_sheet.jpg ``` - When a label is the specific name of a common facility such as a school or residential compound, first open the "图片来源" ("image source") thumbnails to see whether it's the same place (one call): names on images of playgrounds or housing are mostly assembled by voting over similar images; if it isn't the same place, downgrade it to weak and don't bother getting coordinates. - **Facility-type words** in generic labels ("grain drying complex", "cement plant", "greenhouse") are useful: use them to look up where that industry is concentrated, for category inference and candidate areas. - When the label is generic ("blocks of housing", "city street", "school playground"), treat it only as weak evidence; if the whole image + one tight crop, one round in each of two engines, give only generic labels, stop; don't keep trying variants. | Situation | What to do | Source | |---|---|---| | The whole image finds nothing | Box only the most distinctive object (statue, building, ornament); remove the sky and background buildings | v010-5, v010-6 | | Stock photos may have been taken from the other side | Flip horizontally and search again | v010-5 (hit only after flipping) | | Rephotographed material, oblique shots | Perspective-rectify, remove glare | v004 | | Old photos with a yellow or purple cast | Remove the color cast or convert to grayscale | v010-6 | | One engine has no results | Switch engines: Baidu is best for Chinese web pages, Weibo, Baijiahao and scenic-area content; Yandex fills in building and street scenes and foreign content | v009 (Yandex found nothing, Baidu hit) | | The search UI language biases results | Google Lens with a Chinese UI favors similar buildings on Chinese sites; for foreign scenes switch to English or the local language | v004 | **Google Lens**: from the server's egress it demands a CAPTCHA, so the script doesn't do it. If you have a browser tool (such as Claude in Chrome, which uses the user's own browser), use it directly: Google Images home → search by image → upload. In tests it is often stronger than Baidu and Yandex, but keep its two result blocks apart: - "Exact matches / pages that include this image": provenance evidence; usable. - "AI Overview": forces a place name out of similar-looking photos; a different crop of the same image can yield two places a dozen or so km apart. Treat it only as a candidate; it must be verified back at steps 6 and 7. Without a browser tool, when Baidu and Yandex both have no results and the frame shows a foreign landmark or object, ask the user to try Lens once in their own browser. ### Keyword search in the local language Write queries in the local language as well as English. `revimg.py --query "<words>"` runs keyword search on Chinese engines only (Bing China, Baidu Images, Sogou Images); elsewhere use your general web and image search tools. Region packs describe which local engines and platforms work and how to phrase queries (`regions.py show <cc>`). ### After a hit - **Look at the rest of the set**: the hit post is often a set of images, and the others may show plates, road signs or shop names (v009: the plate was in the 5th image of the set). First use shared fixed objects (streetlights, walls, chimneys) to confirm that the photos in the set and the puzzle image are the same place. - **The candidate's background doesn't match**: it may be a replica, an identical installation, or another branch of the same chain. Once you have the proper name, search another round for "<proper name> 复制品 / replica / 同款" (复制品 = replica, 同款 = same model); the official introduction where the original stands often says where the replicas are (v004). - **The hit is only a "similar scene"**: use it as a clue (city, scenic-area name), not as a conclusion; go back to steps 6 and 7 to verify. - **For news images, first judge whether it's a real photo**: many news sites illustrate articles with stock images, illustrations or even generated images; before comparing facades, check whether it is this place. ## 2. Choose where to search by object type Region packs add local platforms per object type (`regions.py show <cc>`). | Object | Where to search | Source | |---|---|---| | Scenic-area buildings, viral check-in spots | The local short-video and photo-sharing platforms (the region pack names them); official scenic-area accounts post videos from the same angle | v010-2, v009 | | Statues, small park features, foreign attractions | User photos on travel review sites (Tripadvisor and local equivalents); Google Lens | v010-6 | | New residential developments, commercial complexes | Development albums and listing photos on local property sites; page all the way to the signboard | v010-5 | | Old buildings, historic sites | Local culture-and-tourism and protected-heritage-site pages; stock image sites (Getty, Alamy and local agencies), whose captions carry place names and years | v004 | | Ordinary streets and residential areas | Image search adds little; prioritize geometry and infrastructure; when even the city isn't fixed, sample one page of arterial-road street view per candidate city and compare municipal fixtures (the street-view script the region pack names) | — | | Sub-brand stores of chain brands (truck service, refurbishment, specialty stores) | **Search opening press releases first** ("开业 / inaugura / abre / opens + sub-brand + state or city"; 开业 = opens), trade media often give the street address; treat the official store locator only as a candidate pool: it doesn't tag sub-brands, and its coordinates may be off by several km | blind test | | Nameless facilities found on satellite imagery (plants, warehouses, farms, mines) | Search the web and news for the facility-type word in the local language + nearby place names, and compare the accompanying photos with the facade; once you find the name, search images another round | v013 | | Vehicle livery (bus, taxi, school bus) | `revimg.py --query "<city> <color description> bus"` in the local language, and read route signs and company names from the result images; first resolve any place name you read to a district with `poi.py` before using it; don't treat a vehicle from district A as a clue for district B | v014 | ## 3. Descriptive keyword image search Suits objects that "you can't name, but have a rare shape" (the v004 fountain). 1. Write the object as a "shape + components + position" phrase: `顶上有金色球形装饰的白色钟楼` (white clock tower with a golden ball ornament on top), `蓝色拱形顶棚的人行天桥` (pedestrian bridge with a blue arched canopy). 2. Prepare both a version **with place names** and one **without**, one set each in the local language and in English, and search them together. Finding nothing with place names is normal: replicas and obscure places have few photos online, while there is more material on the original and on similar objects. 3. If you can't count the shapes, use broad words; don't write a wrong count. 4. Use style words (Romanesque, Gothic, Hui-style) only after checking the form: if the arches are pointed, it isn't Romanesque. ## 4. Social media and check-in spot search - Viral scenery (cherry-blossom streets, ginkgo avenues, influencer walls, check-in cafés): search `<city> + <scenery>` on the local platforms, **in both the local language and English** (English posts about popular foreign streets often include the street name). - Other posts from the same place give more angles (houses, steps, chimneys) and even location tags. - Locals' photos of the same mountains or the same river: look in the candidate town's local feed or its place page on those platforms (the v010-1 creator spoofed the device location to the candidate township to browse local content). - A city usually has only a dozen or so popular check-in streets; find the list first, then check them one by one. ## 5. Open data (use when clearly effective) - **City street-tree data** (species, trunk diameter, height class, address number, coordinates): many cities abroad have open datasets (e.g., Vancouver's `public-trees` on `opendata.vancouver.ca`, about 180,000 trees citywide, `exports/csv` takes about 20 s in one request). Suited to puzzles with "a row of some kind of tree + residential street, no text": it narrows the whole city to a few dozen street segments, with the ground truth among them (proposed in the v009 breakdown, used in one real case). Method: 1. **Pull all species**, not just the target species: later you need to judge "are there trees across the street", and pulling only Prunus would make an opposite side planted with other trees look empty. 2. **Use cultivar and trunk diameter only to sort, never to filter**: the cultivar in the municipal inventory may not match the actual tree (pale pink Somei-Yoshino-type blossoms in the photo; the inventory lists a white-flowered cultivar), and the trunk diameter may have been measured years ago. Cultivars of the same genus with similar flower color all go in as candidates; exclusion can only rely on invariant features in street view. 3. **Use odd/even address numbers to fix which side of the street the trees are on**: comparing the coordinates of odd- and even-numbered trees on the same street tells you (in Vancouver, odd numbers are on the north side of east–west Avenues and the west side of north–south Streets; east–west Streets follow the Avenue rule). Then filter with "which side you're standing on, which way you're facing" from the shadows. 4. **Compute the across-the-street constraint from the field of view**: a portrait phone's horizontal FOV is about 50°, so the grass strip across the street (10–20 m sideways) enters the frame only beyond about 25 m ahead; "no trees across the street" constrains only about 25–50 m ahead, not close by. 5. Add the filtered street segments to the board in one go with `board.py add --from <street-segment JSON> --level road`; when you drop one, write `evidence --against` with a comparison image (SKILL hard rule 9). 6. For street view, first pick historical captures from the same season as the photo (`gsv.py near` to see the history, `sheet --date <year>`) and compare facades, retaining walls and lamp-post positions; in summer captures tree crowns and bloom differ a lot, so don't skip a segment because "the trees look small". - Local government and media reports: after locating, look up the name, size and year built of unusual man-made objects in the frame (scenic spots, towers, statues) (v008). ## 6. Building-class scan (every church / mosque / station in a region) Use for an unnamed building of a recognizable class seen from the street. Narrow to a city or smaller justified bbox first, and check `osm.py coverage` before enumerating: ```bash uv run ${CLAUDE_SKILL_DIR}/scripts/osm.py find --bbox <s,w,n,e> '["building"="church"]' --out targets.json # or ["amenity"="place_of_worship"]["religion"="muslim"], ["railway"="station"] … uv run ${CLAUDE_SKILL_DIR}/scripts/gsv.py targets --points targets.json --per 2 --out targets.index.json --sheet first.jpg uv run ${CLAUDE_SKILL_DIR}/scripts/match.py rank --query photo.jpg --query-box <building part> --items targets.index.json --render gsv --refine sift --top 10 --out r.json --sheet r.jpg ``` - `targets` keeps up to `--per` official panoramas per target, 12–70 m away and ≥15 m apart by default, each facing the building. Coverage tiles are cached and shared by workers. - Keep each index within `match.py`'s default 400-view limit (at most 200 targets with `--per 2`). It loads all candidate images into memory; raising `--max-candidates` to a country-wide view count can exhaust memory. Narrow the bbox or split the point list before scanning, and save each batch's results separately; SIFT refinement within separate batches is not a global ranking. - Compare multiple views of shortlisted targets. Repetitive brickwork can produce false SIFT matches; confirm ≥3 invariant features as in SKILL step 5. - Empty coverage does not exclude a target: the layer can omit panoramas that `gsv.py near <lat,lon> --radius 130` finds. Check shortlisted targets with `near`, including historical captures when appropriate. ## Common mistakes - Using only one engine and searching only the whole image; after a failure, not changing the image or the engine. - Turning the "object category" from an AI or image recognition directly into a location ("boat-shaped sculpture → tourist city"); this jump counts only as weak evidence (v005). - Getting carried off by the "most famous similar place" the search turns up and forgetting to come back and check bearings and details (the main way the AI failed in v010). - Two engines each point at a different city, and you draw a big circle around the midpoint of the two and call it done: instead run a discriminating test and pick one as the main answer (SKILL hard rule 8). - Image search gave a residential compound or housing-development name, and you didn't get its coordinates. -
sky.md 11.9 KB
# Sky clues: sun, shadows, satellite dishes Scripts: `scripts/sun.py` (`pos` / `ratio` / `locate` / `when` / `street` / `facing` / `compass` / `dish`). Compared with NREL SPA, the algorithm's error is ≤0.02°. When there are no landmarks and text only gets you to a province or country, this is the main way to shrink hundreds of kilometers to tens of kilometers (v001: from hundreds of kilometers down to tens). ## When to take this branch | Condition | What you get | |---|---| | Clear shadows + known capture time (puzzle setter's hint, EXIF, screenshot status bar, chat log timestamps) | Sun elevation angle → a band on the map; plus direction → a segment of the band | | Clear shadows + location already fixed | Capture time and date (two candidate sets) | | Satellite TV dishes in the frame | Photo heading (use them as a compass); dish azimuth → longitude range | | The sun itself in the frame (sunrise/sunset, backlight) | Lens heading and the true bearings of objects in the frame (`sun.py compass`); given a clock time, you can also exclude countries with the wrong time zone | | Overcast, night, indoors | Not usable; don't force a calculation | Be clear about the capture time: posting time and IP location are not the capture time and place; treat them only as hypotheses to verify. **Without a time, don't infer road direction, river direction or course from the sun's direction**. The sun at the side of the frame only says the lens is roughly perpendicular to the sun's azimuth; morning, noon and afternoon correspond to completely different directions (the creators in v002, v008 and v012 all reasoned this way and happened to be right; the method doesn't hold). ## 1. Determine the photo heading first In order of reliability: 1. **Satellite dishes**: dishes in the same area point the same way. China's Hu Hu Tong (direct-to-home) small dishes point at ChinaSat 9; in eastern cities they face southwest (about 215°–227°), in the west south or even southeast (see the table in section 5). In Europe dishes generally face south, slightly east. 2. **Shadows or lit faces + time**: north of the Tropic of Cancer, the noon shadow points due north, northwest in the morning and northeast in the afternoon, turning clockwise through the day. With a date and time, compute it; don't estimate: `uv run scripts/sun.py pos --at <approx. location> --time 2025-01-26T08:53 --tz Asia/Shanghai` On winter mornings before 9 a.m. the sun's azimuth is about 120°, not due east (v006 took the sun as due east; the river direction could be off by 30°). Whichever face of a building is brightest faces roughly toward the sun. 3. **Lit faces** (when there's no measurable shadow): a wall is lit if and only if the angle between the sun's azimuth and the wall's normal is less than 90°. Intersect the lit/shaded state of several walls and you get a range for the lens heading: `uv run scripts/sun.py facing --at <approx. location> --time <time> --tz <time zone> --lit left --shaded camera` Describe walls by which way they face in the frame: `camera` faces the lens, `left` faces frame left, `right` faces frame right. At the range boundaries the wall is nearly parallel to the sunlight; don't treat them as hard boundaries. **When shadow direction conflicts with wall brightness or the lit side of tree trunks, trust the shadow** (in two cases, reading wall brightness put the sun on the wrong side): white walls and pale bark also look bright in diffuse light. The easiest thing to read is a car parked at the roadside: use the car length of about 4.5 m as a ruler; how far the shadow extends beyond the front of the car and which side of the car it falls toward directly tell you whether the sun is in front or behind, left or right, and roughly how low its elevation angle is. Tree-shadow stripes on sidewalks come from trees outside the frame and their direction is hard to judge; use them only as a reference. 4. **The sun itself in the frame**: it is the compass. Compute the lens heading from the sun's pixel position; you can also compute the true bearing of any object in the frame (tower, chimney, building corner), to judge "which block is the tower in the photo" within a candidate factory site and which side of the building the camera position is on: `uv run scripts/sun.py compass --at <candidate area> --time <clock time> --dates <possible date range> --tz <time zone> --sun-x <pixels> --width <image width> --hfov <FOV range> --x <object pixels>` - Only a time given, no date: compute over the possible date range, and the bearings become ranges; then add `--elev lo:hi` from how high the sun is above the horizon in the frame; dates whose elevation doesn't match are dropped automatically and the range narrows. - Neither time nor date: `compass` defaults to the whole year, split into a sunrise set and a sunset set. Around 30°N, the sunset azimuth swings between about 240° and 300° over a year; the difference is enough to make a top-down template not match. **Posting time is not capture time**; don't use the posting month to narrow the date. - Telling sunrise from sunset: what the poster says, sky color, shadow changes, road traffic and streetlights. Don't use reasons like "nobody at the school → after school", which holidays and weekends overturn (v014). If you can't tell, build templates for both headings. 5. **Roads on satellite imagery**: once you have location candidates, match the directions of roads, walls and riverbanks in the frame against satellite imagery. 6. Claims like moss or tree crowns leaning one way are unreliable; don't use them. ## 2. Measure the shadow length ratio `sun elevation angle = atan(object height / shadow length)`. Only the ratio is needed, not real sizes. - Pick **vertical** objects (wall corners, utility poles, lamp posts, vertical wall edges) and their shadows on **flat** ground. - Prefer shadows parallel to the image plane (spread sideways); a shadow pointing toward or away from the lens is heavily foreshortened, so pick another object. - For wide-angle photos, first correct barrel distortion, then draw guide lines along the perspective direction to measure (the v001 creator corrected first, then measured). - In the same image, the shadows of vertical objects on flat ground must be parallel to each other. Not parallel = uneven ground, a non-vertical object, an artificial light source or a composite image. - Measure with at least two objects and take a range. A 20% shadow-length error gives a 6–8° elevation-angle error near 45°. ```bash uv run scripts/sun.py ratio --shadow 1.2 # 1:1.2 → 39.8° ``` ## 3. Known time → a band on the map The points where the sun's elevation angle is the same at the same moment form a large circle centered on the subsolar point; adding tolerance makes it a band. Intersect it with the existing candidate areas. ```bash # Beijing time 2023-08-15 16:20, shadow ratio 1.2, ±1°, candidate area in North China uv run scripts/sun.py locate --time 2023-08-15T16:20 --tz Asia/Shanghai --ratio 1.2 --tol 1 \ --bbox 34,110,42,122 --step 0.05 --mosaic north.jpg --out band.jpg # Add the shadow bearing (northeast, 60°±10°) and the band shrinks to a segment uv run scripts/sun.py locate ... --shadow-bearing 60 --az-tol 10 ``` - When the time is uncertain, add `--time-tol 30` (±30 minutes). - `--mosaic` draws the band directly on a low-zoom base map from `tiles.py fetch` (zoom 7–9), for the evidence image. - Verified: plugging in a video puzzle's known answer point and the time the puzzle setter gave, the computed shadow ratio matched the creator's measurement. ### China-specific: a single Beijing time = a longitude clue The whole country uses UTC+8, yet spans 73°E–135°E. Beijing time of solar noon (shadow pointing due north): Shanghai about 11:56, Lanzhou about 13:06, Lhasa about 13:57, Urumqi about 14:11 (June; it shifts by about another ±15 minutes over the year). So **knowing the clock time + being able to see the shadow direction** directly constrains longitude: if the shadow still points due north at 2 p.m., it can't be in the east. ## 4. Known location → capture time and date ```bash uv run scripts/sun.py when --at 30.25,120.16 --date 2024-10-01 --tz Asia/Shanghai --ratio 1.2 --shadow-bearing 30 uv run scripts/sun.py when --at 30.25,120.16 --dates 2024-01-01:2024-12-31 --tz Asia/Shanghai --elev 40 --shadow-bearing 330 ``` - The same sun position occurs twice a year (symmetric about the summer or winter solstice), so results always come in two sets of dates; pick one using vegetation (fallen leaves, flowering, rice-paddy color) and clothing. - Date resolution is high around the equinoxes; for several weeks around the solstices dates are nearly indistinguishable; say so when reporting. - The same elevation angle occurs at two times in a day, morning and afternoon; separate them by shadow **direction** (v007: 08:39 shadow to the northwest, 15:03 shadow to the northeast). - **Report the sensitivity before the time**: `when` prints "1° off ≈ N minutes". Write out how many minutes a 1 m error in object height and a 1 m error in shadow length each correspond to (v007: each 1 m of object-height error shifted the time by about 13 minutes; the creator's ±5 minutes was luck). - Align the shadow tip with the edge that actually casts it (eave, canopy, railing top), not with the base of the wall. ### Known city and date → street orientation Only the angle of the shadow's **direction** relative to the street lets you infer the street orientation; shadow length doesn't (v009 inferred the orientation from shadow length; the reasoning doesn't hold). ```bash # In the top-down view, turning clockwise from the street direction to the shadow direction is 90° (shadow perpendicular to the street); if you can't tell clockwise from counterclockwise, add --both uv run scripts/sun.py street --at 49.25,-123.10 --date 2025-04-01 --tz America/Vancouver --ratio 1.5 --tol 4 --shadow-rel 90 ``` Outputs two sets of directions, morning and afternoon. In a grid city one direction cuts out half the streets; when the date is uncertain, try several days and see how much the direction changes. ## 5. Satellite TV dishes Dishes point at geostationary satellites above the equator; the azimuth is determined by "the difference between the local longitude and the satellite's longitude". China's Hu Hu Tong / Cun Cun Tong (direct-to-home / village coverage) small dishes (35–60 cm diameter, common on rooftops in rural areas and urban villages) almost all point at ChinaSat 9 (92.2°E): | City | Azimuth | Elevation | |---|---|---| | Harbin | 224° | 27° | | Beijing | 215° | 38° | | Shanghai | 227° | 42° | | Guangzhou | 225° | 54° | | Lanzhou | 199° | 46° | | Chengdu | 202° | 52° | | Kunming | 204° | 59° | | Lhasa | 178° | 55° | | Urumqi | 173° | 39° | | Kashgar | 156° | 41° | ```bash uv run scripts/sun.py dish --at 31.2,121.5 --sat 92.2 # forward uv run scripts/sun.py dish --lat 31.2 --sat 92.2 --azimuth 213 # dish faces 213° → longitude about 109°–113° ``` - Azimuth readings are more reliable than elevation. Small dishes are mostly offset-feed; the dish face looks more "upright" than its actual pointing (offset angle 20°–25°), so don't take the dish face tilt as the elevation angle. - Large dishes (1.5 m and up) at institutions and cable TV stations may point at ChinaSat 6B (115.5°) or an AsiaSat satellite; you can't apply the Hu Hu Tong table to them. - Common in Europe: Astra 1 (19.2°E, Germany/Austria), Hot Bird (13°E, Italy), Astra 2 (28.2°E, UK). There's a list in `sun.py`. ## Common mistakes - Wrong time zone or DST: use an IANA name for `--tz` (`Europe/Berlin` handles DST automatically); don't hand-write +1/+2. - Taking posting time as capture time. - Measuring shadow length on slopes or steps; taking a slanted shadow (from a non-vertical object) as the shadow of a vertical object. - Computing a band and looking only at its center line: the whole band within tolerance must be checked; prioritize the parts that intersect other clues. -
verify.md 14.1 KB
# Confirmation and falsification Used in steps 6–8. In the videos, the creators' most common problem isn't failing to find the place but **confirming too loosely**: declaring after checking only two items (v007), claiming a "two-meter error" from one alignment line (v006), dropping a pin and calling it found (v005). AI's problem is worse: saying it "measured on the map" when it didn't (v010). The rules in this file are mandatory. ## 1. Write falsification conditions before looking at candidates Before opening satellite imagery or street view, first write 2–3 "if this appears, give up" conditions, for example: - "The road should be on the left of the building; if it's on the right, exclude" - "The distant mountain should be on the far bank of the river; if it's on the same side, exclude" - "Sunset has to be visible from here, so the camera heading must lean west" If it appears, give up; don't look for reasons to explain it away. Confirmation bias is the number-one failure mode of this craft: you find a place that "looks right" and start making excuses for the differences. **Falsification conditions themselves must also be verified**, otherwise they backfire and kill the correct answer: - "From here, the landmark must be in the frame" → first compute it with `geo.py frame` (field-of-view range, occlusion, pixel size); see `geometry.md` section 11. - Details inferred from a distant view, like "the line should turn at the tower" or "the building should have 8 floors" → if not verified, only rank; don't exclude. - Record the candidates excluded by falsification conditions; when no candidate matches, first go back and recheck these conditions. ## 2. What to compare, what not to | Compare (invariant features) | Don't compare (things that change) | |---|---| | Building outline, number of floors, window positions and spacing, balcony shape | Parked cars, pedestrians | | Positions and number of utility poles and streetlights | Signs, ads, awnings (often replaced) | | Curbs, wall corners, steps, manhole cover positions | How much foliage, whether flowers are in bloom | | Ridgelines, riverbank lines, bridge positions | Construction hoarding, temporary structures | - Only **independent** items count: the same sign photographed three times is one item, not three. - To road level: ≥2 unique features; to building level: ≥3, plus two independent camera-position constraints. - Common features like "factory buildings all faced with yellow tiles" don't count as unique features. ## 3. Bearing self-check (required before output) Draw a top-down sketch on satellite imagery: camera position → foreground object → midground object → distant landmark, plus the direction of the sun or shadows. Check each item: 1. **Left-right order**: if A is left of B in the photo, it must also be so as seen from the candidate camera position. 2. **Near-far relationship**: if A blocks B in the photo, the candidate camera position, A and B must be roughly collinear, with A nearer. 3. **Heading and sun**: for a sunset photo the camera must face west; if, from the candidate point, getting the image's elements into one frame requires facing southeast, it can't be a sunset (v010-7: the point the AI chose could only shoot sunrise; off by 850 km). 4. **Sight line ahead along the road**: a tower straight ahead in a photo taken from inside a car must actually be in that road's direction of travel (v010-4 corrected a misidentified building with this one check). 5. **Compute the bearing first, then identify the structure**: which block on the satellite image corresponds to the tower, chimney or building corner in the photo is an unverified interpretation. When the sun is in the frame, use `sun.py compass --x <structure pixel>` to compute its true bearing, and search the satellite image along that bearing from the candidate camera position; don't first decide on the satellite image that "this is the tower" and then reason about left and right, and don't judge which block is tall from how the OSM building outlines are cut — for tall structures, look at shadow length and side walls on the satellite image. A misidentification makes the bearing self-check kill the correct candidate. Identifying tall structures on satellite imagery: first find a tall building that is certainly vertical and see which of its walls is visible — the satellite is on that side, and the tops of all tall objects lean the opposite way; only blocks with a visible side wall are tall, and the foot of a tower is at the bottom edge of the side wall, where the shadow starts, not at the center of the roof's projection; shadow direction is set by the sun and is not the same thing as lean direction. When both identifications are plausible, draw both sets of camera positions and check each against the bearing computed by `sun.py compass --x`. 6. **When bearings don't match, check for mirroring and sunrise first**: screenshots of reposted videos are often horizontally flipped. Without text, look at gestures (usually right-handed), clocks, the driver's side of vehicles; when the candidate facility is unique and only left-right doesn't match, build two templates each for "original / mirrored" and "sunset / sunrise" and check each one, then decide whether to downgrade or widen the search. If any item fails, exclude, or downgrade and write down why. ## 4. How to confirm without street view | Situation | Alternative | |---|---| | Mountains, outskirts, looking across a river from afar | `terrain.py view --photo` renders the candidate camera position; compare ridgeline outlines; remember the outline only proves "on this sight line", you still need a second constraint. With many candidates, batch-score first, then overlay (`geometry.md` 7.4) | | Suburban hotels, factory compounds, scenic areas | Map POI photos, real photos of the hotel/scenic area online, tourist photos; compare the type, number and spacing of the pylons on the skyline (v011) | | New housing developments, commercial complexes | Development photo albums on real-estate sites (v010-5) | | Outside China | `gsv.py near / sheet --headings / render` (Google Street View, no key); when the puzzle image is a street view screenshot, use the watermark year with `sheet --date <year>` to get the same capture; clouds and season can also be compared | | Rural areas, factory compounds, farms outside China, with no street view | **First find a name for the facility**: OSM name, nearby place name + the local-language word for the facility type (sawmill: scierie / aserradero and the like), search the web, and compare facade details in images from news, encyclopedias, company websites and blogs (v013's creator confirmed it this way); then try Wikimedia Commons image search by coordinates (no key, see `data-sources.md`); Mapillary needs a token, KartaView has little coverage | | No ground photos of any kind | ≥3 top-down features uniquely matched on satellite imagery + bearing self-check passed: report area level as high, road as medium, building as low, and state that ground-level imagery is missing; don't fall back to district level overall just because there's no street view | ### Finding a storefront along a road When you know the road name (address from a news article, the street given by reverse image search) but not the street number: ```bash uv run scripts/osm.py along --bbox <s,w,n,e> --line '["highway"]["name"~"<road name fragment>"]' --step 120 --out pts.json uv run scripts/gsv.py sheet --points pts.json --headings 30,90 --out road.jpg # in China use baidu_pano.py instead ``` - Spacing ≤150 m: a storefront is only visible within a very narrow angle; at 400 m spacing you can easily miss every single one. - Set headings from "direction of travel + which side the shop is on": in a right-hand-traffic country, shooting a shop on the right from inside a car while heading north, use 20–90°. To reproduce an upward shot of a storefront from inside a car, add `--pitch 15–20`. - To find roadside factory buildings and fields, use satellite thumbnails: `--step 300–500 --side <north|south|east|west> --offset <meters from the road to the building>`, then `tiles.py sheet --zoom 16–17`. ### Picking street view points - When there are many panorama points, group them by capture date and road, and render the ones with an open view first: waterside paths, bridges, intersections, older captures not blocked by street trees. Newer captures on main roads are often completely blocked by trees (the truly usable ones are often older-capture points on waterside paths and construction access roads). - Look around from one point: `baidu_pano.py sheet --ids <id> --headings 0,60,120,180,240,300` (outside China use `gsv.py`). - For farmland and countryside in China, first probe one or two points with `baidu_pano.py near <lat,lon>` to see whether there's a panorama, then decide whether to `scan`: in a real case a 1.5 km-radius `scan` over farmland ran for 5 minutes and found none. ## 5. Overlay comparison When the two images have different perspectives, crop the sign, utility pole or window from the original, rotate and scale it, and paste it onto the corresponding position in the street view screenshot; whether it's the same object is visible at a glance (v002). Same for water and mountain outlines: rotate and stretch, then overlay onto satellite imagery and compare point by point (v012). ```bash uv run scripts/imgprep.py zoom photo.jpg --box ... --scale 2 --out part.png uv run scripts/baidu_pano.py render <panoid> --heading 47 --out sv.jpg uv run scripts/evidence.py spec.json --out evidence.jpg # satellite image + camera-position wedge + comparison panels ``` ## 6. Historical imagery - Street view taken many years before the photo: go by old buildings and permanent structures; new buildings may not exist yet, and signs may have changed (v003: the street view was 10 years older; the comparison used buildings and poles). - New facilities (new schools, new factory buildings) aren't in old street view yet: first group by date and look at the newest capture; if it shows demolition, hoarding or tower cranes, that's ground evidence that "something was built here later", and it also gives the photo an earliest possible year. - Conversely, you can date the photo: find slowly changing things (extent of climbing-plant cover, tree crown size, hoarding, whether a new building has gone up), compare them across street view or satellite imagery from different years, and you can bracket it to within a year or two (v004). - Judge the season from the phenology in the photo, not from the month the reference street view was taken (v004's creator called it "winter" from the date of a December street view). ## 7. When there are many candidate cities or candidate points - Make a candidate table: one row per candidate, one column per criterion, fill in "matches / doesn't match / can't tell", and in the last column write the reason for exclusion. - Look at candidate points one page at a time: `tiles.py sheet --points cands.json --zoom 18`; each cell is centered and numbered, more accurate than finding points in a large mosaic (reading pixels off a large mosaic displayed scaled down is off by 50–100 px). - Criteria use only things directly visible within the photo's field of view. - Go through all of them once before looking closely at what remains; don't stop at the first one that "looks like it". - When candidates come from OSM, first check each candidate area's data volume with `osm.py coverage`; an area with sparse data being absent from the candidate table doesn't mean it isn't the one — scan it separately with `tiles.py sheet --grid`. ### Finding a sports field (common in top-down shots from windows) 1. Read the sports-field template from the photo: the angle between the track's long axis and the camera direction (lying across the frame ≈ perpendicular to the camera direction), which side of the track the pitch and basketball courts are on, the shape of nearby buildings (L-shaped, enclosed square), adjacent open ground or slopes. 2. The camera direction comes from the sun (`sun.py compass`; without a date, compute over the whole year) or from roads or rivers in the frame; if two headings can't be separated, use two templates. 3. First look at the whole urban area in one z16 mosaic; bright new sports fields stand out at a glance; convert pixels to coordinates with `tiles.py px2ll`. When you need to find all fields, new and old, lay a grid over the candidate district's urban area: `tiles.py sheet --grid <urban area s,w,n,e> --zoom 17 --size 320 --cols 5` (one cell ≈ 330 m, 25 cells per page ≈ 1.5 km × 1.5 km), and look for red running tracks cell by cell (a track covered by building shadows shows only a section of arc). 4. Compare matched fields to the template, then work back from the field toward the camera to find the building the camera is in (which side of the teaching building is visible, which road lies between, how large the depression angle is). 5. Don't assume "track lying across the frame = long axis perpendicular to the sight line": see which end of the track's far edge is lower (closer); if it's slanted, it isn't perpendicular. After identifying ≥4 points (track curves, basketball court corners, building corners), solve heading and camera position with `pose.py solve`; don't compute by hand. ## 8. Final check before concluding - [ ] Every "matched" and "measured" has a file actually produced in this session - [ ] The error radius comes from intersection geometry or multi-item comparison, not made-up decimal places - [ ] All four bearing self-check items passed - [ ] After finding an anchor, you didn't go back to generic features to pick another place - [ ] Category inference listed all candidates, with exclusion reasons written - [ ] Conflicts between hints, IP, EXIF and the conclusion are explained (the image wins) - [ ] The key step was redone with a different crop or keywords, with consistent results - [ ] If you couldn't pin a point, you wrote "the level it's determined to + what information is still missing" - [ ] Every exclusion reason was verified: exclusions by "X isn't in the image" had the frame and occlusion computed; unverified interpretations were used only for ranking - [ ] When the candidates are a few discrete ones, you chose a main answer, not a midpoint with a big circle
-
-
regions
-
ca
-
clues.md 1.1 KB
# Canada clues Entries use the shared format (`references/clues/README.md`). Cross-country clues that lead here (driving side, plate shape, script) are in `references/clues/global.md`. ## Clues ### British Columbia, Canada plates - Look for: blue characters on white, "two letters, one digit + small provincial flag in the middle + two digits, one letter" - Points to: BC - Strength: strong (when both the format and the provincial flag are clearly visible) - Counterexamples: out-of-province cars are common in Vancouver; older formats differ - Sources: v009 ### Residential streets with dark green streetlight poles + a grass strip outside the sidewalk - Look for: on residential streets, streetlight poles painted dark green with curved-arm lamp heads; a grass strip separating sidewalk and roadway; shallow gutters - Points to: residential areas of Vancouver and Metro Vancouver (use together with plates) - Strength: weak - Counterexamples: residential areas in the US Northwest (around Seattle and Portland) look very similar; grass strips are very common in North American suburbs - Sources: v009 -
region.json 154 B
{ "code": "CA", "name": "Canada", "names": [ "Canada", "加拿大", "CA" ], "status": "stub", "maintainers": [], "docs": [ "clues.md" ] }
-
-
cn
-
data
-
admin.json 325.2 KB
{ "_meta": { "source": [ "https://raw.githubusercontent.com/modood/Administrative-divisions-of-China/master/dist/pca-code.json" ], "fetched": "2026-09-14", "license": "WTFPL (modood/Administrative-divisions-of-China)", "count": 3420 }, "items": [ { "name": "北京市", "code": "11", "level": 1, "parent": "" }, { "name": "东城区", "code": "110101", "level": 3, "parent": "北京市" }, { "name": "西城区", "code": "110102", "level": 3, "parent": "北京市" }, { "name": "朝阳区", "code": "110105", "level": 3, "parent": "北京市" }, { "name": "丰台区", "code": "110106", "level": 3, "parent": "北京市" }, { "name": "石景山区", "code": "110107", "level": 3, "parent": "北京市" }, { "name": "海淀区", "code": "110108", "level": 3, "parent": "北京市" }, { "name": "门头沟区", "code": "110109", "level": 3, "parent": "北京市" }, { "name": "房山区", "code": "110111", "level": 3, "parent": "北京市" }, { "name": "通州区", "code": "110112", "level": 3, "parent": "北京市" }, { "name": "顺义区", "code": "110113", "level": 3, "parent": "北京市" }, { "name": "昌平区", "code": "110114", "level": 3, "parent": "北京市" }, { "name": "大兴区", "code": "110115", "level": 3, "parent": "北京市" }, { "name": "怀柔区", "code": "110116", "level": 3, "parent": "北京市" }, { "name": "平谷区", "code": "110117", "level": 3, "parent": "北京市" }, { "name": "密云区", "code": "110118", "level": 3, "parent": "北京市" }, { "name": "延庆区", "code": "110119", "level": 3, "parent": "北京市" }, { "name": "天津市", "code": "12", "level": 1, "parent": "" }, { "name": "和平区", "code": "120101", "level": 3, "parent": "天津市" }, { "name": "河东区", "code": "120102", "level": 3, "parent": "天津市" }, { "name": "河西区", "code": "120103", "level": 3, "parent": "天津市" }, { "name": "南开区", "code": "120104", "level": 3, "parent": "天津市" }, { "name": "河北区", "code": "120105", "level": 3, "parent": "天津市" }, { "name": "红桥区", "code": "120106", "level": 3, "parent": "天津市" }, { "name": "东丽区", "code": "120110", "level": 3, "parent": "天津市" }, { "name": "西青区", "code": "120111", "level": 3, "parent": "天津市" }, { "name": "津南区", "code": "120112", "level": 3, "parent": "天津市" }, { "name": "北辰区", "code": "120113", "level": 3, "parent": "天津市" }, { "name": "武清区", "code": "120114", "level": 3, "parent": "天津市" }, { "name": "宝坻区", "code": "120115", "level": 3, "parent": "天津市" }, { "name": "滨海新区", "code": "120116", "level": 3, "parent": "天津市" }, { "name": "宁河区", "code": "120117", "level": 3, "parent": "天津市" }, { "name": "静海区", "code": "120118", "level": 3, "parent": "天津市" }, { "name": "蓟州区", "code": "120119", "level": 3, "parent": "天津市" }, { "name": "河北省", "code": "13", "level": 1, "parent": "" }, { "name": "石家庄市", "code": "1301", "level": 2, "parent": "河北省" }, { "name": "长安区", "code": "130102", "level": 3, "parent": "石家庄市" }, { "name": "桥西区", "code": "130104", "level": 3, "parent": "石家庄市" }, { "name": "新华区", "code": "130105", "level": 3, "parent": "石家庄市" }, { "name": "井陉矿区", "code": "130107", "level": 3, "parent": "石家庄市" }, { "name": "裕华区", "code": "130108", "level": 3, "parent": "石家庄市" }, { "name": "藁城区", "code": "130109", "level": 3, "parent": "石家庄市" }, { "name": "鹿泉区", "code": "130110", "level": 3, "parent": "石家庄市" }, { "name": "栾城区", "code": "130111", "level": 3, "parent": "石家庄市" }, { "name": "井陉县", "code": "130121", "level": 3, "parent": "石家庄市" }, { "name": "正定县", "code": "130123", "level": 3, "parent": "石家庄市" }, { "name": "行唐县", "code": "130125", "level": 3, "parent": "石家庄市" }, { "name": "灵寿县", "code": "130126", "level": 3, "parent": "石家庄市" }, { "name": "高邑县", "code": "130127", "level": 3, "parent": "石家庄市" }, { "name": "深泽县", "code": "130128", "level": 3, "parent": "石家庄市" }, { "name": "赞皇县", "code": "130129", "level": 3, "parent": "石家庄市" }, { "name": "无极县", "code": "130130", "level": 3, "parent": "石家庄市" }, { "name": "平山县", "code": "130131", "level": 3, "parent": "石家庄市" }, { "name": "元氏县", "code": "130132", "level": 3, "parent": "石家庄市" }, { "name": "赵县", "code": "130133", "level": 3, "parent": "石家庄市" }, { "name": "石家庄高新技术产业开发区", "code": "130171", "level": 3, "parent": "石家庄市" }, { "name": "石家庄循环化工园区", "code": "130172", "level": 3, "parent": "石家庄市" }, { "name": "辛集市", "code": "130181", "level": 3, "parent": "石家庄市" }, { "name": "晋州市", "code": "130183", "level": 3, "parent": "石家庄市" }, { "name": "新乐市", "code": "130184", "level": 3, "parent": "石家庄市" }, { "name": "唐山市", "code": "1302", "level": 2, "parent": "河北省" }, { "name": "路南区", "code": "130202", "level": 3, "parent": "唐山市" }, { "name": "路北区", "code": "130203", "level": 3, "parent": "唐山市" }, { "name": "古冶区", "code": "130204", "level": 3, "parent": "唐山市" }, { "name": "开平区", "code": "130205", "level": 3, "parent": "唐山市" }, { "name": "丰南区", "code": "130207", "level": 3, "parent": "唐山市" }, { "name": "丰润区", "code": "130208", "level": 3, "parent": "唐山市" }, { "name": "曹妃甸区", "code": "130209", "level": 3, "parent": "唐山市" }, { "name": "滦南县", "code": "130224", "level": 3, "parent": "唐山市" }, { "name": "乐亭县", "code": "130225", "level": 3, "parent": "唐山市" }, { "name": "迁西县", "code": "130227", "level": 3, "parent": "唐山市" }, { "name": "玉田县", "code": "130229", "level": 3, "parent": "唐山市" }, { "name": "河北唐山芦台经济开发区", "code": "130271", "level": 3, "parent": "唐山市" }, { "name": "唐山市汉沽管理区", "code": "130272", "level": 3, "parent": "唐山市" }, { "name": "唐山高新技术产业开发区", "code": "130273", "level": 3, "parent": "唐山市" }, { "name": "河北唐山海港经济开发区", "code": "130274", "level": 3, "parent": "唐山市" }, { "name": "遵化市", "code": "130281", "level": 3, "parent": "唐山市" }, { "name": "迁安市", "code": "130283", "level": 3, "parent": "唐山市" }, { "name": "滦州市", "code": "130284", "level": 3, "parent": "唐山市" }, { "name": "秦皇岛市", "code": "1303", "level": 2, "parent": "河北省" }, { "name": "海港区", "code": "130302", "level": 3, "parent": "秦皇岛市" }, { "name": "山海关区", "code": "130303", "level": 3, "parent": "秦皇岛市" }, { "name": "北戴河区", "code": "130304", "level": 3, "parent": "秦皇岛市" }, { "name": "抚宁区", "code": "130306", "level": 3, "parent": "秦皇岛市" }, { "name": "青龙满族自治县", "code": "130321", "level": 3, "parent": "秦皇岛市" }, { "name": "昌黎县", "code": "130322", "level": 3, "parent": "秦皇岛市" }, { "name": "卢龙县", "code": "130324", "level": 3, "parent": "秦皇岛市" }, { "name": "秦皇岛市经济技术开发区", "code": "130371", "level": 3, "parent": "秦皇岛市" }, { "name": "北戴河新区", "code": "130372", "level": 3, "parent": "秦皇岛市" }, { "name": "邯郸市", "code": "1304", "level": 2, "parent": "河北省" }, { "name": "邯山区", "code": "130402", "level": 3, "parent": "邯郸市" }, { "name": "丛台区", "code": "130403", "level": 3, "parent": "邯郸市" }, { "name": "复兴区", "code": "130404", "level": 3, "parent": "邯郸市" }, { "name": "峰峰矿区", "code": "130406", "level": 3, "parent": "邯郸市" }, { "name": "肥乡区", "code": "130407", "level": 3, "parent": "邯郸市" }, { "name": "永年区", "code": "130408", "level": 3, "parent": "邯郸市" }, { "name": "临漳县", "code": "130423", "level": 3, "parent": "邯郸市" }, { "name": "成安县", "code": "130424", "level": 3, "parent": "邯郸市" }, { "name": "大名县", "code": "130425", "level": 3, "parent": "邯郸市" }, { "name": "涉县", "code": "130426", "level": 3, "parent": "邯郸市" }, { "name": "磁县", "code": "130427", "level": 3, "parent": "邯郸市" }, { "name": "邱县", "code": "130430", "level": 3, "parent": "邯郸市" }, { "name": "鸡泽县", "code": "130431", "level": 3, "parent": "邯郸市" }, { "name": "广平县", "code": "130432", "level": 3, "parent": "邯郸市" }, { "name": "馆陶县", "code": "130433", "level": 3, "parent": "邯郸市" }, { "name": "魏县", "code": "130434", "level": 3, "parent": "邯郸市" }, { "name": "曲周县", "code": "130435", "level": 3, "parent": "邯郸市" }, { "name": "邯郸经济技术开发区", "code": "130471", "level": 3, "parent": "邯郸市" }, { "name": "邯郸冀南新区", "code": "130473", "level": 3, "parent": "邯郸市" }, { "name": "武安市", "code": "130481", "level": 3, "parent": "邯郸市" }, { "name": "邢台市", "code": "1305", "level": 2, "parent": "河北省" }, { "name": "襄都区", "code": "130502", "level": 3, "parent": "邢台市" }, { "name": "信都区", "code": "130503", "level": 3, "parent": "邢台市" }, { "name": "任泽区", "code": "130505", "level": 3, "parent": "邢台市" }, { "name": "南和区", "code": "130506", "level": 3, "parent": "邢台市" }, { "name": "临城县", "code": "130522", "level": 3, "parent": "邢台市" }, { "name": "内丘县", "code": "130523", "level": 3, "parent": "邢台市" }, { "name": "柏乡县", "code": "130524", "level": 3, "parent": "邢台市" }, { "name": "隆尧县", "code": "130525", "level": 3, "parent": "邢台市" }, { "name": "宁晋县", "code": "130528", "level": 3, "parent": "邢台市" }, { "name": "巨鹿县", "code": "130529", "level": 3, "parent": "邢台市" }, { "name": "新河县", "code": "130530", "level": 3, "parent": "邢台市" }, { "name": "广宗县", "code": "130531", "level": 3, "parent": "邢台市" }, { "name": "平乡县", "code": "130532", "level": 3, "parent": "邢台市" }, { "name": "威县", "code": "130533", "level": 3, "parent": "邢台市" }, { "name": "清河县", "code": "130534", "level": 3, "parent": "邢台市" }, { "name": "临西县", "code": "130535", "level": 3, "parent": "邢台市" }, { "name": "河北邢台经济开发区", "code": "130571", "level": 3, "parent": "邢台市" }, { "name": "南宫市", "code": "130581", "level": 3, "parent": "邢台市" }, { "name": "沙河市", "code": "130582", "level": 3, "parent": "邢台市" }, { "name": "保定市", "code": "1306", "level": 2, "parent": "河北省" }, { "name": "竞秀区", "code": "130602", "level": 3, "parent": "保定市" }, { "name": "莲池区", "code": "130606", "level": 3, "parent": "保定市" }, { "name": "满城区", "code": "130607", "level": 3, "parent": "保定市" }, { "name": "清苑区", "code": "130608", "level": 3, "parent": "保定市" }, { "name": "徐水区", "code": "130609", "level": 3, "parent": "保定市" }, { "name": "涞水县", "code": "130623", "level": 3, "parent": "保定市" }, { "name": "阜平县", "code": "130624", "level": 3, "parent": "保定市" }, { "name": "定兴县", "code": "130626", "level": 3, "parent": "保定市" }, { "name": "唐县", "code": "130627", "level": 3, "parent": "保定市" }, { "name": "高阳县", "code": "130628", "level": 3, "parent": "保定市" }, { "name": "容城县", "code": "130629", "level": 3, "parent": "保定市" }, { "name": "涞源县", "code": "130630", "level": 3, "parent": "保定市" }, { "name": "望都县", "code": "130631", "level": 3, "parent": "保定市" }, { "name": "安新县", "code": "130632", "level": 3, "parent": "保定市" }, { "name": "易县", "code": "130633", "level": 3, "parent": "保定市" }, { "name": "曲阳县", "code": "130634", "level": 3, "parent": "保定市" }, { "name": "蠡县", "code": "130635", "level": 3, "parent": "保定市" }, { "name": "顺平县", "code": "130636", "level": 3, "parent": "保定市" }, { "name": "博野县", "code": "130637", "level": 3, "parent": "保定市" }, { "name": "雄县", "code": "130638", "level": 3, "parent": "保定市" }, { "name": "保定高新技术产业开发区", "code": "130671", "level": 3, "parent": "保定市" }, { "name": "保定白沟新城", "code": "130672", "level": 3, "parent": "保定市" }, { "name": "涿州市", "code": "130681", "level": 3, "parent": "保定市" }, { "name": "定州市", "code": "130682", "level": 3, "parent": "保定市" }, { "name": "安国市", "code": "130683", "level": 3, "parent": "保定市" }, { "name": "高碑店市", "code": "130684", "level": 3, "parent": "保定市" }, { "name": "张家口市", "code": "1307", "level": 2, "parent": "河北省" }, { "name": "桥东区", "code": "130702", "level": 3, "parent": "张家口市" }, { "name": "桥西区", "code": "130703", "level": 3, "parent": "张家口市" }, { "name": "宣化区", "code": "130705", "level": 3, "parent": "张家口市" }, { "name": "下花园区", "code": "130706", "level": 3, "parent": "张家口市" }, { "name": "万全区", "code": "130708", "level": 3, "parent": "张家口市" }, { "name": "崇礼区", "code": "130709", "level": 3, "parent": "张家口市" }, { "name": "张北县", "code": "130722", "level": 3, "parent": "张家口市" }, { "name": "康保县", "code": "130723", "level": 3, "parent": "张家口市" }, { "name": "沽源县", "code": "130724", "level": 3, "parent": "张家口市" }, { "name": "尚义县", "code": "130725", "level": 3, "parent": "张家口市" }, { "name": "蔚县", "code": "130726", "level": 3, "parent": "张家口市" }, { "name": "阳原县", "code": "130727", "level": 3, "parent": "张家口市" }, { "name": "怀安县", "code": "130728", "level": 3, "parent": "张家口市" }, { "name": "怀来县", "code": "130730", "level": 3, "parent": "张家口市" }, { "name": "涿鹿县", "code": "130731", "level": 3, "parent": "张家口市" }, { "name": "赤城县", "code": "130732", "level": 3, "parent": "张家口市" }, { "name": "张家口经济开发区", "code": "130771", "level": 3, "parent": "张家口市" }, { "name": "张家口市察北管理区", "code": "130772", "level": 3, "parent": "张家口市" }, { "name": "张家口市塞北管理区", "code": "130773", "level": 3, "parent": "张家口市" }, { "name": "承德市", "code": "1308", "level": 2, "parent": "河北省" }, { "name": "双桥区", "code": "130802", "level": 3, "parent": "承德市" }, { "name": "双滦区", "code": "130803", "level": 3, "parent": "承德市" }, { "name": "鹰手营子矿区", "code": "130804", "level": 3, "parent": "承德市" }, { "name": "承德县", "code": "130821", "level": 3, "parent": "承德市" }, { "name": "兴隆县", "code": "130822", "level": 3, "parent": "承德市" }, { "name": "滦平县", "code": "130824", "level": 3, "parent": "承德市" }, { "name": "隆化县", "code": "130825", "level": 3, "parent": "承德市" }, { "name": "丰宁满族自治县", "code": "130826", "level": 3, "parent": "承德市" }, { "name": "宽城满族自治县", "code": "130827", "level": 3, "parent": "承德市" }, { "name": "围场满族蒙古族自治县", "code": "130828", "level": 3, "parent": "承德市" }, { "name": "承德高新技术产业开发区", "code": "130871", "level": 3, "parent": "承德市" }, { "name": "平泉市", "code": "130881", "level": 3, "parent": "承德市" }, { "name": "沧州市", "code": "1309", "level": 2, "parent": "河北省" }, { "name": "新华区", "code": "130902", "level": 3, "parent": "沧州市" }, { "name": "运河区", "code": "130903", "level": 3, "parent": "沧州市" }, { "name": "沧县", "code": "130921", "level": 3, "parent": "沧州市" }, { "name": "青县", "code": "130922", "level": 3, "parent": "沧州市" }, { "name": "东光县", "code": "130923", "level": 3, "parent": "沧州市" }, { "name": "海兴县", "code": "130924", "level": 3, "parent": "沧州市" }, { "name": "盐山县", "code": "130925", "level": 3, "parent": "沧州市" }, { "name": "肃宁县", "code": "130926", "level": 3, "parent": "沧州市" }, { "name": "南皮县", "code": "130927", "level": 3, "parent": "沧州市" }, { "name": "吴桥县", "code": "130928", "level": 3, "parent": "沧州市" }, { "name": "献县", "code": "130929", "level": 3, "parent": "沧州市" }, { "name": "孟村回族自治县", "code": "130930", "level": 3, "parent": "沧州市" }, { "name": "河北沧州经济开发区", "code": "130971", "level": 3, "parent": "沧州市" }, { "name": "沧州高新技术产业开发区", "code": "130972", "level": 3, "parent": "沧州市" }, { "name": "沧州渤海新区", "code": "130973", "level": 3, "parent": "沧州市" }, { "name": "泊头市", "code": "130981", "level": 3, "parent": "沧州市" }, { "name": "任丘市", "code": "130982", "level": 3, "parent": "沧州市" }, { "name": "黄骅市", "code": "130983", "level": 3, "parent": "沧州市" }, { "name": "河间市", "code": "130984", "level": 3, "parent": "沧州市" }, { "name": "廊坊市", "code": "1310", "level": 2, "parent": "河北省" }, { "name": "安次区", "code": "131002", "level": 3, "parent": "廊坊市" }, { "name": "广阳区", "code": "131003", "level": 3, "parent": "廊坊市" }, { "name": "固安县", "code": "131022", "level": 3, "parent": "廊坊市" }, { "name": "永清县", "code": "131023", "level": 3, "parent": "廊坊市" }, { "name": "香河县", "code": "131024", "level": 3, "parent": "廊坊市" }, { "name": "大城县", "code": "131025", "level": 3, "parent": "廊坊市" }, { "name": "文安县", "code": "131026", "level": 3, "parent": "廊坊市" }, { "name": "大厂回族自治县", "code": "131028", "level": 3, "parent": "廊坊市" }, { "name": "廊坊经济技术开发区", "code": "131071", "level": 3, "parent": "廊坊市" }, { "name": "霸州市", "code": "131081", "level": 3, "parent": "廊坊市" }, { "name": "三河市", "code": "131082", "level": 3, "parent": "廊坊市" }, { "name": "衡水市", "code": "1311", "level": 2, "parent": "河北省" }, { "name": "桃城区", "code": "131102", "level": 3, "parent": "衡水市" }, { "name": "冀州区", "code": "131103", "level": 3, "parent": "衡水市" }, { "name": "枣强县", "code": "131121", "level": 3, "parent": "衡水市" }, { "name": "武邑县", "code": "131122", "level": 3, "parent": "衡水市" }, { "name": "武强县", "code": "131123", "level": 3, "parent": "衡水市" }, { "name": "饶阳县", "code": "131124", "level": 3, "parent": "衡水市" }, { "name": "安平县", "code": "131125", "level": 3, "parent": "衡水市" }, { "name": "故城县", "code": "131126", "level": 3, "parent": "衡水市" }, { "name": "景县", "code": "131127", "level": 3, "parent": "衡水市" }, { "name": "阜城县", "code": "131128", "level": 3, "parent": "衡水市" }, { "name": "河北衡水高新技术产业开发区", "code": "131171", "level": 3, "parent": "衡水市" }, { "name": "衡水滨湖新区", "code": "131172", "level": 3, "parent": "衡水市" }, { "name": "深州市", "code": "131182", "level": 3, "parent": "衡水市" }, { "name": "山西省", "code": "14", "level": 1, "parent": "" }, { "name": "太原市", "code": "1401", "level": 2, "parent": "山西省" }, { "name": "小店区", "code": "140105", "level": 3, "parent": "太原市" }, { "name": "迎泽区", "code": "140106", "level": 3, "parent": "太原市" }, { "name": "杏花岭区", "code": "140107", "level": 3, "parent": "太原市" }, { "name": "尖草坪区", "code": "140108", "level": 3, "parent": "太原市" }, { "name": "万柏林区", "code": "140109", "level": 3, "parent": "太原市" }, { "name": "晋源区", "code": "140110", "level": 3, "parent": "太原市" }, { "name": "清徐县", "code": "140121", "level": 3, "parent": "太原市" }, { "name": "阳曲县", "code": "140122", "level": 3, "parent": "太原市" }, { "name": "娄烦县", "code": "140123", "level": 3, "parent": "太原市" }, { "name": "山西转型综合改革示范区", "code": "140171", "level": 3, "parent": "太原市" }, { "name": "古交市", "code": "140181", "level": 3, "parent": "太原市" }, { "name": "大同市", "code": "1402", "level": 2, "parent": "山西省" }, { "name": "新荣区", "code": "140212", "level": 3, "parent": "大同市" }, { "name": "平城区", "code": "140213", "level": 3, "parent": "大同市" }, { "name": "云冈区", "code": "140214", "level": 3, "parent": "大同市" }, { "name": "云州区", "code": "140215", "level": 3, "parent": "大同市" }, { "name": "阳高县", "code": "140221", "level": 3, "parent": "大同市" }, { "name": "天镇县", "code": "140222", "level": 3, "parent": "大同市" }, { "name": "广灵县", "code": "140223", "level": 3, "parent": "大同市" }, { "name": "灵丘县", "code": "140224", "level": 3, "parent": "大同市" }, { "name": "浑源县", "code": "140225", "level": 3, "parent": "大同市" }, { "name": "左云县", "code": "140226", "level": 3, "parent": "大同市" }, { "name": "山西大同经济开发区", "code": "140271", "level": 3, "parent": "大同市" }, { "name": "阳泉市", "code": "1403", "level": 2, "parent": "山西省" }, { "name": "城区", "code": "140302", "level": 3, "parent": "阳泉市" }, { "name": "矿区", "code": "140303", "level": 3, "parent": "阳泉市" }, { "name": "郊区", "code": "140311", "level": 3, "parent": "阳泉市" }, { "name": "平定县", "code": "140321", "level": 3, "parent": "阳泉市" }, { "name": "盂县", "code": "140322", "level": 3, "parent": "阳泉市" }, { "name": "长治市", "code": "1404", "level": 2, "parent": "山西省" }, { "name": "潞州区", "code": "140403", "level": 3, "parent": "长治市" }, { "name": "上党区", "code": "140404", "level": 3, "parent": "长治市" }, { "name": "屯留区", "code": "140405", "level": 3, "parent": "长治市" }, { "name": "潞城区", "code": "140406", "level": 3, "parent": "长治市" }, { "name": "襄垣县", "code": "140423", "level": 3, "parent": "长治市" }, { "name": "平顺县", "code": "140425", "level": 3, "parent": "长治市" }, { "name": "黎城县", "code": "140426", "level": 3, "parent": "长治市" }, { "name": "壶关县", "code": "140427", "level": 3, "parent": "长治市" }, { "name": "长子县", "code": "140428", "level": 3, "parent": "长治市" }, { "name": "武乡县", "code": "140429", "level": 3, "parent": "长治市" }, { "name": "沁县", "code": "140430", "level": 3, "parent": "长治市" }, { "name": "沁源县", "code": "140431", "level": 3, "parent": "长治市" }, { "name": "晋城市", "code": "1405", "level": 2, "parent": "山西省" }, { "name": "城区", "code": "140502", "level": 3, "parent": "晋城市" }, { "name": "沁水县", "code": "140521", "level": 3, "parent": "晋城市" }, { "name": "阳城县", "code": "140522", "level": 3, "parent": "晋城市" }, { "name": "陵川县", "code": "140524", "level": 3, "parent": "晋城市" }, { "name": "泽州县", "code": "140525", "level": 3, "parent": "晋城市" }, { "name": "高平市", "code": "140581", "level": 3, "parent": "晋城市" }, { "name": "朔州市", "code": "1406", "level": 2, "parent": "山西省" }, { "name": "朔城区", "code": "140602", "level": 3, "parent": "朔州市" }, { "name": "平鲁区", "code": "140603", "level": 3, "parent": "朔州市" }, { "name": "山阴县", "code": "140621", "level": 3, "parent": "朔州市" }, { "name": "应县", "code": "140622", "level": 3, "parent": "朔州市" }, { "name": "右玉县", "code": "140623", "level": 3, "parent": "朔州市" }, { "name": "山西朔州经济开发区", "code": "140671", "level": 3, "parent": "朔州市" }, { "name": "怀仁市", "code": "140681", "level": 3, "parent": "朔州市" }, { "name": "晋中市", "code": "1407", "level": 2, "parent": "山西省" }, { "name": "榆次区", "code": "140702", "level": 3, "parent": "晋中市" }, { "name": "太谷区", "code": "140703", "level": 3, "parent": "晋中市" }, { "name": "榆社县", "code": "140721", "level": 3, "parent": "晋中市" }, { "name": "左权县", "code": "140722", "level": 3, "parent": "晋中市" }, { "name": "和顺县", "code": "140723", "level": 3, "parent": "晋中市" }, { "name": "昔阳县", "code": "140724", "level": 3, "parent": "晋中市" }, { "name": "寿阳县", "code": "140725", "level": 3, "parent": "晋中市" }, { "name": "祁县", "code": "140727", "level": 3, "parent": "晋中市" }, { "name": "平遥县", "code": "140728", "level": 3, "parent": "晋中市" }, { "name": "灵石县", "code": "140729", "level": 3, "parent": "晋中市" }, { "name": "介休市", "code": "140781", "level": 3, "parent": "晋中市" }, { "name": "运城市", "code": "1408", "level": 2, "parent": "山西省" }, { "name": "盐湖区", "code": "140802", "level": 3, "parent": "运城市" }, { "name": "临猗县", "code": "140821", "level": 3, "parent": "运城市" }, { "name": "万荣县", "code": "140822", "level": 3, "parent": "运城市" }, { "name": "闻喜县", "code": "140823", "level": 3, "parent": "运城市" }, { "name": "稷山县", "code": "140824", "level": 3, "parent": "运城市" }, { "name": "新绛县", "code": "140825", "level": 3, "parent": "运城市" }, { "name": "绛县", "code": "140826", "level": 3, "parent": "运城市" }, { "name": "垣曲县", "code": "140827", "level": 3, "parent": "运城市" }, { "name": "夏县", "code": "140828", "level": 3, "parent": "运城市" }, { "name": "平陆县", "code": "140829", "level": 3, "parent": "运城市" }, { "name": "芮城县", "code": "140830", "level": 3, "parent": "运城市" }, { "name": "永济市", "code": "140881", "level": 3, "parent": "运城市" }, { "name": "河津市", "code": "140882", "level": 3, "parent": "运城市" }, { "name": "忻州市", "code": "1409", "level": 2, "parent": "山西省" }, { "name": "忻府区", "code": "140902", "level": 3, "parent": "忻州市" }, { "name": "定襄县", "code": "140921", "level": 3, "parent": "忻州市" }, { "name": "五台县", "code": "140922", "level": 3, "parent": "忻州市" }, { "name": "代县", "code": "140923", "level": 3, "parent": "忻州市" }, { "name": "繁峙县", "code": "140924", "level": 3, "parent": "忻州市" }, { "name": "宁武县", "code": "140925", "level": 3, "parent": "忻州市" }, { "name": "静乐县", "code": "140926", "level": 3, "parent": "忻州市" }, { "name": "神池县", "code": "140927", "level": 3, "parent": "忻州市" }, { "name": "五寨县", "code": "140928", "level": 3, "parent": "忻州市" }, { "name": "岢岚县", "code": "140929", "level": 3, "parent": "忻州市" }, { "name": "河曲县", "code": "140930", "level": 3, "parent": "忻州市" }, { "name": "保德县", "code": "140931", "level": 3, "parent": "忻州市" }, { "name": "偏关县", "code": "140932", "level": 3, "parent": "忻州市" }, { "name": "五台山风景名胜区", "code": "140971", "level": 3, "parent": "忻州市" }, { "name": "原平市", "code": "140981", "level": 3, "parent": "忻州市" }, { "name": "临汾市", "code": "1410", "level": 2, "parent": "山西省" }, { "name": "尧都区", "code": "141002", "level": 3, "parent": "临汾市" }, { "name": "曲沃县", "code": "141021", "level": 3, "parent": "临汾市" }, { "name": "翼城县", "code": "141022", "level": 3, "parent": "临汾市" }, { "name": "襄汾县", "code": "141023", "level": 3, "parent": "临汾市" }, { "name": "洪洞县", "code": "141024", "level": 3, "parent": "临汾市" }, { "name": "古县", "code": "141025", "level": 3, "parent": "临汾市" }, { "name": "安泽县", "code": "141026", "level": 3, "parent": "临汾市" }, { "name": "浮山县", "code": "141027", "level": 3, "parent": "临汾市" }, { "name": "吉县", "code": "141028", "level": 3, "parent": "临汾市" }, { "name": "乡宁县", "code": "141029", "level": 3, "parent": "临汾市" }, { "name": "大宁县", "code": "141030", "level": 3, "parent": "临汾市" }, { "name": "隰县", "code": "141031", "level": 3, "parent": "临汾市" }, { "name": "永和县", "code": "141032", "level": 3, "parent": "临汾市" }, { "name": "蒲县", "code": "141033", "level": 3, "parent": "临汾市" }, { "name": "汾西县", "code": "141034", "level": 3, "parent": "临汾市" }, { "name": "侯马市", "code": "141081", "level": 3, "parent": "临汾市" }, { "name": "霍州市", "code": "141082", "level": 3, "parent": "临汾市" }, { "name": "吕梁市", "code": "1411", "level": 2, "parent": "山西省" }, { "name": "离石区", "code": "141102", "level": 3, "parent": "吕梁市" }, { "name": "文水县", "code": "141121", "level": 3, "parent": "吕梁市" }, { "name": "交城县", "code": "141122", "level": 3, "parent": "吕梁市" }, { "name": "兴县", "code": "141123", "level": 3, "parent": "吕梁市" }, { "name": "临县", "code": "141124", "level": 3, "parent": "吕梁市" }, { "name": "柳林县", "code": "141125", "level": 3, "parent": "吕梁市" }, { "name": "石楼县", "code": "141126", "level": 3, "parent": "吕梁市" }, { "name": "岚县", "code": "141127", "level": 3, "parent": "吕梁市" }, { "name": "方山县", "code": "141128", "level": 3, "parent": "吕梁市" }, { "name": "中阳县", "code": "141129", "level": 3, "parent": "吕梁市" }, { "name": "交口县", "code": "141130", "level": 3, "parent": "吕梁市" }, { "name": "孝义市", "code": "141181", "level": 3, "parent": "吕梁市" }, { "name": "汾阳市", "code": "141182", "level": 3, "parent": "吕梁市" }, { "name": "内蒙古自治区", "code": "15", "level": 1, "parent": "" }, { "name": "呼和浩特市", "code": "1501", "level": 2, "parent": "内蒙古自治区" }, { "name": "新城区", "code": "150102", "level": 3, "parent": "呼和浩特市" }, { "name": "回民区", "code": "150103", "level": 3, "parent": "呼和浩特市" }, { "name": "玉泉区", "code": "150104", "level": 3, "parent": "呼和浩特市" }, { "name": "赛罕区", "code": "150105", "level": 3, "parent": "呼和浩特市" }, { "name": "土默特左旗", "code": "150121", "level": 3, "parent": "呼和浩特市" }, { "name": "托克托县", "code": "150122", "level": 3, "parent": "呼和浩特市" }, { "name": "和林格尔县", "code": "150123", "level": 3, "parent": "呼和浩特市" }, { "name": "清水河县", "code": "150124", "level": 3, "parent": "呼和浩特市" }, { "name": "武川县", "code": "150125", "level": 3, "parent": "呼和浩特市" }, { "name": "呼和浩特经济技术开发区", "code": "150172", "level": 3, "parent": "呼和浩特市" }, { "name": "包头市", "code": "1502", "level": 2, "parent": "内蒙古自治区" }, { "name": "东河区", "code": "150202", "level": 3, "parent": "包头市" }, { "name": "昆都仑区", "code": "150203", "level": 3, "parent": "包头市" }, { "name": "青山区", "code": "150204", "level": 3, "parent": "包头市" }, { "name": "石拐区", "code": "150205", "level": 3, "parent": "包头市" }, { "name": "白云鄂博矿区", "code": "150206", "level": 3, "parent": "包头市" }, { "name": "九原区", "code": "150207", "level": 3, "parent": "包头市" }, { "name": "土默特右旗", "code": "150221", "level": 3, "parent": "包头市" }, { "name": "固阳县", "code": "150222", "level": 3, "parent": "包头市" }, { "name": "达尔罕茂明安联合旗", "code": "150223", "level": 3, "parent": "包头市" }, { "name": "包头稀土高新技术产业开发区", "code": "150271", "level": 3, "parent": "包头市" }, { "name": "乌海市", "code": "1503", "level": 2, "parent": "内蒙古自治区" }, { "name": "海勃湾区", "code": "150302", "level": 3, "parent": "乌海市" }, { "name": "海南区", "code": "150303", "level": 3, "parent": "乌海市" }, { "name": "乌达区", "code": "150304", "level": 3, "parent": "乌海市" }, { "name": "赤峰市", "code": "1504", "level": 2, "parent": "内蒙古自治区" }, { "name": "红山区", "code": "150402", "level": 3, "parent": "赤峰市" }, { "name": "元宝山区", "code": "150403", "level": 3, "parent": "赤峰市" }, { "name": "松山区", "code": "150404", "level": 3, "parent": "赤峰市" }, { "name": "阿鲁科尔沁旗", "code": "150421", "level": 3, "parent": "赤峰市" }, { "name": "巴林左旗", "code": "150422", "level": 3, "parent": "赤峰市" }, { "name": "巴林右旗", "code": "150423", "level": 3, "parent": "赤峰市" }, { "name": "林西县", "code": "150424", "level": 3, "parent": "赤峰市" }, { "name": "克什克腾旗", "code": "150425", "level": 3, "parent": "赤峰市" }, { "name": "翁牛特旗", "code": "150426", "level": 3, "parent": "赤峰市" }, { "name": "喀喇沁旗", "code": "150428", "level": 3, "parent": "赤峰市" }, { "name": "宁城县", "code": "150429", "level": 3, "parent": "赤峰市" }, { "name": "敖汉旗", "code": "150430", "level": 3, "parent": "赤峰市" }, { "name": "通辽市", "code": "1505", "level": 2, "parent": "内蒙古自治区" }, { "name": "科尔沁区", "code": "150502", "level": 3, "parent": "通辽市" }, { "name": "科尔沁左翼中旗", "code": "150521", "level": 3, "parent": "通辽市" }, { "name": "科尔沁左翼后旗", "code": "150522", "level": 3, "parent": "通辽市" }, { "name": "开鲁县", "code": "150523", "level": 3, "parent": "通辽市" }, { "name": "库伦旗", "code": "150524", "level": 3, "parent": "通辽市" }, { "name": "奈曼旗", "code": "150525", "level": 3, "parent": "通辽市" }, { "name": "扎鲁特旗", "code": "150526", "level": 3, "parent": "通辽市" }, { "name": "通辽经济技术开发区", "code": "150571", "level": 3, "parent": "通辽市" }, { "name": "霍林郭勒市", "code": "150581", "level": 3, "parent": "通辽市" }, { "name": "鄂尔多斯市", "code": "1506", "level": 2, "parent": "内蒙古自治区" }, { "name": "东胜区", "code": "150602", "level": 3, "parent": "鄂尔多斯市" }, { "name": "康巴什区", "code": "150603", "level": 3, "parent": "鄂尔多斯市" }, { "name": "达拉特旗", "code": "150621", "level": 3, "parent": "鄂尔多斯市" }, { "name": "准格尔旗", "code": "150622", "level": 3, "parent": "鄂尔多斯市" }, { "name": "鄂托克前旗", "code": "150623", "level": 3, "parent": "鄂尔多斯市" }, { "name": "鄂托克旗", "code": "150624", "level": 3, "parent": "鄂尔多斯市" }, { "name": "杭锦旗", "code": "150625", "level": 3, "parent": "鄂尔多斯市" }, { "name": "乌审旗", "code": "150626", "level": 3, "parent": "鄂尔多斯市" }, { "name": "伊金霍洛旗", "code": "150627", "level": 3, "parent": "鄂尔多斯市" }, { "name": "呼伦贝尔市", "code": "1507", "level": 2, "parent": "内蒙古自治区" }, { "name": "海拉尔区", "code": "150702", "level": 3, "parent": "呼伦贝尔市" }, { "name": "扎赉诺尔区", "code": "150703", "level": 3, "parent": "呼伦贝尔市" }, { "name": "阿荣旗", "code": "150721", "level": 3, "parent": "呼伦贝尔市" }, { "name": "莫力达瓦达斡尔族自治旗", "code": "150722", "level": 3, "parent": "呼伦贝尔市" }, { "name": "鄂伦春自治旗", "code": "150723", "level": 3, "parent": "呼伦贝尔市" }, { "name": "鄂温克族自治旗", "code": "150724", "level": 3, "parent": "呼伦贝尔市" }, { "name": "陈巴尔虎旗", "code": "150725", "level": 3, "parent": "呼伦贝尔市" }, { "name": "新巴尔虎左旗", "code": "150726", "level": 3, "parent": "呼伦贝尔市" }, { "name": "新巴尔虎右旗", "code": "150727", "level": 3, "parent": "呼伦贝尔市" }, { "name": "满洲里市", "code": "150781", "level": 3, "parent": "呼伦贝尔市" }, { "name": "牙克石市", "code": "150782", "level": 3, "parent": "呼伦贝尔市" }, { "name": "扎兰屯市", "code": "150783", "level": 3, "parent": "呼伦贝尔市" }, { "name": "额尔古纳市", "code": "150784", "level": 3, "parent": "呼伦贝尔市" }, { "name": "根河市", "code": "150785", "level": 3, "parent": "呼伦贝尔市" }, { "name": "巴彦淖尔市", "code": "1508", "level": 2, "parent": "内蒙古自治区" }, { "name": "临河区", "code": "150802", "level": 3, "parent": "巴彦淖尔市" }, { "name": "五原县", "code": "150821", "level": 3, "parent": "巴彦淖尔市" }, { "name": "磴口县", "code": "150822", "level": 3, "parent": "巴彦淖尔市" }, { "name": "乌拉特前旗", "code": "150823", "level": 3, "parent": "巴彦淖尔市" }, { "name": "乌拉特中旗", "code": "150824", "level": 3, "parent": "巴彦淖尔市" }, { "name": "乌拉特后旗", "code": "150825", "level": 3, "parent": "巴彦淖尔市" }, { "name": "杭锦后旗", "code": "150826", "level": 3, "parent": "巴彦淖尔市" }, { "name": "乌兰察布市", "code": "1509", "level": 2, "parent": "内蒙古自治区" }, { "name": "集宁区", "code": "150902", "level": 3, "parent": "乌兰察布市" }, { "name": "卓资县", "code": "150921", "level": 3, "parent": "乌兰察布市" }, { "name": "化德县", "code": "150922", "level": 3, "parent": "乌兰察布市" }, { "name": "商都县", "code": "150923", "level": 3, "parent": "乌兰察布市" }, { "name": "兴和县", "code": "150924", "level": 3, "parent": "乌兰察布市" }, { "name": "凉城县", "code": "150925", "level": 3, "parent": "乌兰察布市" }, { "name": "察哈尔右翼前旗", "code": "150926", "level": 3, "parent": "乌兰察布市" }, { "name": "察哈尔右翼中旗", "code": "150927", "level": 3, "parent": "乌兰察布市" }, { "name": "察哈尔右翼后旗", "code": "150928", "level": 3, "parent": "乌兰察布市" }, { "name": "四子王旗", "code": "150929", "level": 3, "parent": "乌兰察布市" }, { "name": "丰镇市", "code": "150981", "level": 3, "parent": "乌兰察布市" }, { "name": "兴安盟", "code": "1522", "level": 2, "parent": "内蒙古自治区" }, { "name": "乌兰浩特市", "code": "152201", "level": 3, "parent": "兴安盟" }, { "name": "阿尔山市", "code": "152202", "level": 3, "parent": "兴安盟" }, { "name": "科尔沁右翼前旗", "code": "152221", "level": 3, "parent": "兴安盟" }, { "name": "科尔沁右翼中旗", "code": "152222", "level": 3, "parent": "兴安盟" }, { "name": "扎赉特旗", "code": "152223", "level": 3, "parent": "兴安盟" }, { "name": "突泉县", "code": "152224", "level": 3, "parent": "兴安盟" }, { "name": "锡林郭勒盟", "code": "1525", "level": 2, "parent": "内蒙古自治区" }, { "name": "二连浩特市", "code": "152501", "level": 3, "parent": "锡林郭勒盟" }, { "name": "锡林浩特市", "code": "152502", "level": 3, "parent": "锡林郭勒盟" }, { "name": "阿巴嘎旗", "code": "152522", "level": 3, "parent": "锡林郭勒盟" }, { "name": "苏尼特左旗", "code": "152523", "level": 3, "parent": "锡林郭勒盟" }, { "name": "苏尼特右旗", "code": "152524", "level": 3, "parent": "锡林郭勒盟" }, { "name": "东乌珠穆沁旗", "code": "152525", "level": 3, "parent": "锡林郭勒盟" }, { "name": "西乌珠穆沁旗", "code": "152526", "level": 3, "parent": "锡林郭勒盟" }, { "name": "太仆寺旗", "code": "152527", "level": 3, "parent": "锡林郭勒盟" }, { "name": "镶黄旗", "code": "152528", "level": 3, "parent": "锡林郭勒盟" }, { "name": "正镶白旗", "code": "152529", "level": 3, "parent": "锡林郭勒盟" }, { "name": "正蓝旗", "code": "152530", "level": 3, "parent": "锡林郭勒盟" }, { "name": "多伦县", "code": "152531", "level": 3, "parent": "锡林郭勒盟" }, { "name": "乌拉盖管理区管委会", "code": "152571", "level": 3, "parent": "锡林郭勒盟" }, { "name": "阿拉善盟", "code": "1529", "level": 2, "parent": "内蒙古自治区" }, { "name": "阿拉善左旗", "code": "152921", "level": 3, "parent": "阿拉善盟" }, { "name": "阿拉善右旗", "code": "152922", "level": 3, "parent": "阿拉善盟" }, { "name": "额济纳旗", "code": "152923", "level": 3, "parent": "阿拉善盟" }, { "name": "内蒙古阿拉善高新技术产业开发区", "code": "152971", "level": 3, "parent": "阿拉善盟" }, { "name": "辽宁省", "code": "21", "level": 1, "parent": "" }, { "name": "沈阳市", "code": "2101", "level": 2, "parent": "辽宁省" }, { "name": "和平区", "code": "210102", "level": 3, "parent": "沈阳市" }, { "name": "沈河区", "code": "210103", "level": 3, "parent": "沈阳市" }, { "name": "大东区", "code": "210104", "level": 3, "parent": "沈阳市" }, { "name": "皇姑区", "code": "210105", "level": 3, "parent": "沈阳市" }, { "name": "铁西区", "code": "210106", "level": 3, "parent": "沈阳市" }, { "name": "苏家屯区", "code": "210111", "level": 3, "parent": "沈阳市" }, { "name": "浑南区", "code": "210112", "level": 3, "parent": "沈阳市" }, { "name": "沈北新区", "code": "210113", "level": 3, "parent": "沈阳市" }, { "name": "于洪区", "code": "210114", "level": 3, "parent": "沈阳市" }, { "name": "辽中区", "code": "210115", "level": 3, "parent": "沈阳市" }, { "name": "康平县", "code": "210123", "level": 3, "parent": "沈阳市" }, { "name": "法库县", "code": "210124", "level": 3, "parent": "沈阳市" }, { "name": "新民市", "code": "210181", "level": 3, "parent": "沈阳市" }, { "name": "大连市", "code": "2102", "level": 2, "parent": "辽宁省" }, { "name": "中山区", "code": "210202", "level": 3, "parent": "大连市" }, { "name": "西岗区", "code": "210203", "level": 3, "parent": "大连市" }, { "name": "沙河口区", "code": "210204", "level": 3, "parent": "大连市" }, { "name": "甘井子区", "code": "210211", "level": 3, "parent": "大连市" }, { "name": "旅顺口区", "code": "210212", "level": 3, "parent": "大连市" }, { "name": "金州区", "code": "210213", "level": 3, "parent": "大连市" }, { "name": "普兰店区", "code": "210214", "level": 3, "parent": "大连市" }, { "name": "长海县", "code": "210224", "level": 3, "parent": "大连市" }, { "name": "瓦房店市", "code": "210281", "level": 3, "parent": "大连市" }, { "name": "庄河市", "code": "210283", "level": 3, "parent": "大连市" }, { "name": "鞍山市", "code": "2103", "level": 2, "parent": "辽宁省" }, { "name": "铁东区", "code": "210302", "level": 3, "parent": "鞍山市" }, { "name": "铁西区", "code": "210303", "level": 3, "parent": "鞍山市" }, { "name": "立山区", "code": "210304", "level": 3, "parent": "鞍山市" }, { "name": "千山区", "code": "210311", "level": 3, "parent": "鞍山市" }, { "name": "台安县", "code": "210321", "level": 3, "parent": "鞍山市" }, { "name": "岫岩满族自治县", "code": "210323", "level": 3, "parent": "鞍山市" }, { "name": "海城市", "code": "210381", "level": 3, "parent": "鞍山市" }, { "name": "抚顺市", "code": "2104", "level": 2, "parent": "辽宁省" }, { "name": "新抚区", "code": "210402", "level": 3, "parent": "抚顺市" }, { "name": "东洲区", "code": "210403", "level": 3, "parent": "抚顺市" }, { "name": "望花区", "code": "210404", "level": 3, "parent": "抚顺市" }, { "name": "顺城区", "code": "210411", "level": 3, "parent": "抚顺市" }, { "name": "抚顺县", "code": "210421", "level": 3, "parent": "抚顺市" }, { "name": "新宾满族自治县", "code": "210422", "level": 3, "parent": "抚顺市" }, { "name": "清原满族自治县", "code": "210423", "level": 3, "parent": "抚顺市" }, { "name": "本溪市", "code": "2105", "level": 2, "parent": "辽宁省" }, { "name": "平山区", "code": "210502", "level": 3, "parent": "本溪市" }, { "name": "溪湖区", "code": "210503", "level": 3, "parent": "本溪市" }, { "name": "明山区", "code": "210504", "level": 3, "parent": "本溪市" }, { "name": "南芬区", "code": "210505", "level": 3, "parent": "本溪市" }, { "name": "本溪满族自治县", "code": "210521", "level": 3, "parent": "本溪市" }, { "name": "桓仁满族自治县", "code": "210522", "level": 3, "parent": "本溪市" }, { "name": "丹东市", "code": "2106", "level": 2, "parent": "辽宁省" }, { "name": "元宝区", "code": "210602", "level": 3, "parent": "丹东市" }, { "name": "振兴区", "code": "210603", "level": 3, "parent": "丹东市" }, { "name": "振安区", "code": "210604", "level": 3, "parent": "丹东市" }, { "name": "宽甸满族自治县", "code": "210624", "level": 3, "parent": "丹东市" }, { "name": "东港市", "code": "210681", "level": 3, "parent": "丹东市" }, { "name": "凤城市", "code": "210682", "level": 3, "parent": "丹东市" }, { "name": "锦州市", "code": "2107", "level": 2, "parent": "辽宁省" }, { "name": "古塔区", "code": "210702", "level": 3, "parent": "锦州市" }, { "name": "凌河区", "code": "210703", "level": 3, "parent": "锦州市" }, { "name": "太和区", "code": "210711", "level": 3, "parent": "锦州市" }, { "name": "黑山县", "code": "210726", "level": 3, "parent": "锦州市" }, { "name": "义县", "code": "210727", "level": 3, "parent": "锦州市" }, { "name": "凌海市", "code": "210781", "level": 3, "parent": "锦州市" }, { "name": "北镇市", "code": "210782", "level": 3, "parent": "锦州市" }, { "name": "营口市", "code": "2108", "level": 2, "parent": "辽宁省" }, { "name": "站前区", "code": "210802", "level": 3, "parent": "营口市" }, { "name": "西市区", "code": "210803", "level": 3, "parent": "营口市" }, { "name": "鲅鱼圈区", "code": "210804", "level": 3, "parent": "营口市" }, { "name": "老边区", "code": "210811", "level": 3, "parent": "营口市" }, { "name": "盖州市", "code": "210881", "level": 3, "parent": "营口市" }, { "name": "大石桥市", "code": "210882", "level": 3, "parent": "营口市" }, { "name": "阜新市", "code": "2109", "level": 2, "parent": "辽宁省" }, { "name": "海州区", "code": "210902", "level": 3, "parent": "阜新市" }, { "name": "新邱区", "code": "210903", "level": 3, "parent": "阜新市" }, { "name": "太平区", "code": "210904", "level": 3, "parent": "阜新市" }, { "name": "清河门区", "code": "210905", "level": 3, "parent": "阜新市" }, { "name": "细河区", "code": "210911", "level": 3, "parent": "阜新市" }, { "name": "阜新蒙古族自治县", "code": "210921", "level": 3, "parent": "阜新市" }, { "name": "彰武县", "code": "210922", "level": 3, "parent": "阜新市" }, { "name": "辽阳市", "code": "2110", "level": 2, "parent": "辽宁省" }, { "name": "白塔区", "code": "211002", "level": 3, "parent": "辽阳市" }, { "name": "文圣区", "code": "211003", "level": 3, "parent": "辽阳市" }, { "name": "宏伟区", "code": "211004", "level": 3, "parent": "辽阳市" }, { "name": "弓长岭区", "code": "211005", "level": 3, "parent": "辽阳市" }, { "name": "太子河区", "code": "211011", "level": 3, "parent": "辽阳市" }, { "name": "辽阳县", "code": "211021", "level": 3, "parent": "辽阳市" }, { "name": "灯塔市", "code": "211081", "level": 3, "parent": "辽阳市" }, { "name": "盘锦市", "code": "2111", "level": 2, "parent": "辽宁省" }, { "name": "双台子区", "code": "211102", "level": 3, "parent": "盘锦市" }, { "name": "兴隆台区", "code": "211103", "level": 3, "parent": "盘锦市" }, { "name": "大洼区", "code": "211104", "level": 3, "parent": "盘锦市" }, { "name": "盘山县", "code": "211122", "level": 3, "parent": "盘锦市" }, { "name": "铁岭市", "code": "2112", "level": 2, "parent": "辽宁省" }, { "name": "银州区", "code": "211202", "level": 3, "parent": "铁岭市" }, { "name": "清河区", "code": "211204", "level": 3, "parent": "铁岭市" }, { "name": "铁岭县", "code": "211221", "level": 3, "parent": "铁岭市" }, { "name": "西丰县", "code": "211223", "level": 3, "parent": "铁岭市" }, { "name": "昌图县", "code": "211224", "level": 3, "parent": "铁岭市" }, { "name": "调兵山市", "code": "211281", "level": 3, "parent": "铁岭市" }, { "name": "开原市", "code": "211282", "level": 3, "parent": "铁岭市" }, { "name": "朝阳市", "code": "2113", "level": 2, "parent": "辽宁省" }, { "name": "双塔区", "code": "211302", "level": 3, "parent": "朝阳市" }, { "name": "龙城区", "code": "211303", "level": 3, "parent": "朝阳市" }, { "name": "朝阳县", "code": "211321", "level": 3, "parent": "朝阳市" }, { "name": "建平县", "code": "211322", "level": 3, "parent": "朝阳市" }, { "name": "喀喇沁左翼蒙古族自治县", "code": "211324", "level": 3, "parent": "朝阳市" }, { "name": "北票市", "code": "211381", "level": 3, "parent": "朝阳市" }, { "name": "凌源市", "code": "211382", "level": 3, "parent": "朝阳市" }, { "name": "葫芦岛市", "code": "2114", "level": 2, "parent": "辽宁省" }, { "name": "连山区", "code": "211402", "level": 3, "parent": "葫芦岛市" }, { "name": "龙港区", "code": "211403", "level": 3, "parent": "葫芦岛市" }, { "name": "南票区", "code": "211404", "level": 3, "parent": "葫芦岛市" }, { "name": "绥中县", "code": "211421", "level": 3, "parent": "葫芦岛市" }, { "name": "建昌县", "code": "211422", "level": 3, "parent": "葫芦岛市" }, { "name": "兴城市", "code": "211481", "level": 3, "parent": "葫芦岛市" }, { "name": "吉林省", "code": "22", "level": 1, "parent": "" }, { "name": "长春市", "code": "2201", "level": 2, "parent": "吉林省" }, { "name": "南关区", "code": "220102", "level": 3, "parent": "长春市" }, { "name": "宽城区", "code": "220103", "level": 3, "parent": "长春市" }, { "name": "朝阳区", "code": "220104", "level": 3, "parent": "长春市" }, { "name": "二道区", "code": "220105", "level": 3, "parent": "长春市" }, { "name": "绿园区", "code": "220106", "level": 3, "parent": "长春市" }, { "name": "双阳区", "code": "220112", "level": 3, "parent": "长春市" }, { "name": "九台区", "code": "220113", "level": 3, "parent": "长春市" }, { "name": "农安县", "code": "220122", "level": 3, "parent": "长春市" }, { "name": "长春经济技术开发区", "code": "220171", "level": 3, "parent": "长春市" }, { "name": "长春净月高新技术产业开发区", -
area_codes.json 60.7 KB
{ "_meta": { "kind": "area-code", "normalize": { "digits_only": true, "ensure_prefix": "0" }, "usage": "landline area code; accepts 0817, 0817-1234567, (0817) 123. 010/02X are 3 digits, the rest 4", "miss_note": "not a mainland China landline area code (mobile number, 400/800, foreign number)", "source": [ "https://zh.wikipedia.org/zh-cn/中国大陆固定电话号码" ], "fetched": "2026-09-14", "license": "Derived from Wikipedia, CC BY-SA 4.0", "count": 349 }, "entries": { "010": [ { "admin1": "北京市", "note": "直辖市全境; 北京市为华北大区中心局,且有C1局交换机连往国外。北京市原使用区号“1”,1990年代后期为了腾出码号资源用于移动电话和其他专用网络,改为当前区号。", "digits": "8位" } ], "021": [ { "admin1": "上海市", "note": "直辖市全境; 该市的C1局交换机连往国外。", "digits": "8位" } ], "022": [ { "admin1": "天津市", "note": "直辖市全境", "digits": "8位" } ], "023": [ { "admin1": "重庆市", "note": "直辖市全境; 华北使用3字头三位区号,但是华北区因为北京兼任大区交换中心,故原属于华北区大区中心的区号23曾长期空缺。重庆市升格为直辖市后,主城区弃用原有区号811改用区号23,而主城区以外的地区也先后弃用原有区号改用区号23,最终实现重庆全市统一区号。", "digits": "8位" } ], "024": [ { "admin1": "辽宁省", "admin2": "沈阳市", "note": "沈阳市为东北大区中心局,东北使用4字头三位区号。", "digits": "8位" }, { "admin1": "辽宁省", "admin2": "抚顺市", "note": "沈阳市为东北大区中心局,东北使用4字头三位区号。", "digits": "8位" }, { "admin1": "辽宁省", "admin2": "铁岭市", "note": "沈阳市为东北大区中心局,东北使用4字头三位区号。", "digits": "8位" }, { "admin1": "辽宁省", "admin2": "本溪市", "note": "沈阳市为东北大区中心局,东北使用4字头三位区号。", "digits": "8位" } ], "025": [ { "admin1": "江苏省", "admin2": "南京市", "note": "南京市为华东大区中心局,华东使用5字头三位区号。", "digits": "8位" } ], "026": [ { "admin1": "未分配", "note": "一般认为是预留用作台湾大区交换中心区号,亦即台北市区号,但是因为两岸分治并未投入使用,目前拨打该区号无法接通任何号码,从中国大陆拨打电话至台湾须加拨国际区号886,拨打方式和收费标准均参照国际长途。中国大陆有部分城市曾尝试获得此区号但未能成功。", "digits": "未分配" } ], "027": [ { "admin1": "湖北省", "admin2": "武汉市", "note": "武汉市为中南大区中心局,中南使用7字头三位区号。", "digits": "8位" }, { "admin1": "湖北省", "admin2": "鄂州市", "district": "华容区", "note": "武汉市为中南大区中心局,中南使用7字头三位区号。", "digits": "8位" } ], "028": [ { "admin1": "四川省", "admin2": "成都市", "note": "成都市为西南大区中心局,西南使用8字头三位区号。", "digits": "8位" }, { "admin1": "四川省", "admin2": "眉山市", "note": "成都市为西南大区中心局,西南使用8字头三位区号。", "digits": "8位" }, { "admin1": "四川省", "admin2": "资阳市", "note": "成都市为西南大区中心局,西南使用8字头三位区号。", "digits": "8位" } ], "029": [ { "admin1": "陕西省", "admin2": "西安市", "note": "西安市为西北大区中心局,西北使用9字头三位区号交换机。", "digits": "8位" }, { "admin1": "陕西省", "admin2": "咸阳市", "note": "西安市为西北大区中心局,西北使用9字头三位区号交换机。", "digits": "8位" } ], "020": [ { "admin1": "广东省", "admin2": "广州市", "note": "广州市为华南大区中心局,华南使用中南大区的7字头三位区号,且有C1局交换机连往国外。", "digits": "8位" } ], "0311": [ { "admin1": "河北省", "admin2": "石家庄市", "note": "", "digits": "8位" } ], "0312": [ { "admin1": "河北省", "admin2": "保定市", "note": "", "digits": "7位" } ], "0313": [ { "admin1": "河北省", "admin2": "张家口市", "note": "", "digits": "7位" } ], "0314": [ { "admin1": "河北省", "admin2": "承德市", "note": "", "digits": "7位" } ], "0315": [ { "admin1": "河北省", "admin2": "唐山市", "note": "", "digits": "7位" } ], "0316": [ { "admin1": "河北省", "admin2": "廊坊市", "note": "", "digits": "7位" } ], "0317": [ { "admin1": "河北省", "admin2": "沧州市", "note": "", "digits": "7位" } ], "0318": [ { "admin1": "河北省", "admin2": "衡水市", "note": "", "digits": "7位" } ], "0319": [ { "admin1": "河北省", "admin2": "邢台市", "note": "", "digits": "7位" } ], "0310": [ { "admin1": "河北省", "admin2": "邯郸市", "note": "", "digits": "7位" } ], "0335": [ { "admin1": "河北省", "admin2": "秦皇岛市", "note": "", "digits": "7位" } ], "0349": [ { "admin1": "山西省", "admin2": "朔州市", "note": "", "digits": "7位" } ], "0351": [ { "admin1": "山西省", "admin2": "太原市", "note": "", "digits": "7位" } ], "0352": [ { "admin1": "山西省", "admin2": "大同市", "note": "", "digits": "7位" } ], "0353": [ { "admin1": "山西省", "admin2": "阳泉市", "note": "", "digits": "7位" } ], "0354": [ { "admin1": "山西省", "admin2": "晋中市", "note": "", "digits": "7位" } ], "0355": [ { "admin1": "山西省", "admin2": "长治市", "note": "", "digits": "7位" } ], "0356": [ { "admin1": "山西省", "admin2": "晋城市", "note": "", "digits": "7位" } ], "0357": [ { "admin1": "山西省", "admin2": "临汾市", "note": "", "digits": "7位" } ], "0358": [ { "admin1": "山西省", "admin2": "吕梁市", "note": "", "digits": "7位" } ], "0359": [ { "admin1": "山西省", "admin2": "运城市", "note": "", "digits": "7位" } ], "0350": [ { "admin1": "山西省", "admin2": "忻州市", "note": "", "digits": "7位" } ], "0371": [ { "admin1": "河南省", "admin2": "郑州市", "note": "", "digits": "8位" }, { "admin1": "河南省", "admin2": "开封市", "note": "", "digits": "8位" } ], "0372": [ { "admin1": "河南省", "admin2": "安阳市", "note": "", "digits": "7位" } ], "0373": [ { "admin1": "河南省", "admin2": "新乡市", "note": "", "digits": "7位" } ], "0374": [ { "admin1": "河南省", "admin2": "许昌市", "note": "", "digits": "7位" } ], "0375": [ { "admin1": "河南省", "admin2": "平顶山市", "note": "", "digits": "7位" } ], "0376": [ { "admin1": "河南省", "admin2": "信阳市", "note": "", "digits": "7位" } ], "0377": [ { "admin1": "河南省", "admin2": "南阳市", "note": "", "digits": "8位" } ], "0378": [ { "admin1": "河南省", "admin2": "开封市", "note": "deprecated; 2013年10月26日0时起,开封市与郑州市合并使用区号371,该区号弃用。", "digits": "/" } ], "0379": [ { "admin1": "河南省", "admin2": "洛阳市", "note": "", "digits": "8位" } ], "0370": [ { "admin1": "河南省", "admin2": "商丘市", "note": "", "digits": "7位" } ], "0391": [ { "admin1": "河南省", "admin2": "焦作市", "note": "", "digits": "7位" }, { "admin1": "河南省", "district": "济源市", "note": "", "digits": "7位" } ], "0392": [ { "admin1": "河南省", "admin2": "鹤壁市", "note": "", "digits": "7位" } ], "0393": [ { "admin1": "河南省", "admin2": "濮阳市", "note": "", "digits": "7位" } ], "0394": [ { "admin1": "河南省", "admin2": "周口市", "note": "", "digits": "7位" } ], "0395": [ { "admin1": "河南省", "admin2": "漯河市", "note": "", "digits": "7位" } ], "0396": [ { "admin1": "河南省", "admin2": "驻马店市", "note": "", "digits": "7位" } ], "0397": [ { "admin1": "河南省", "admin2": "信阳市", "note": "东部七县; deprecated; 2005年11月20日0时起,潢川县、新县、息县、固始县、淮滨县、商城县和光山县与信阳市其他地区合并使用区号376,该区号弃用。", "digits": "/" } ], "0398": [ { "admin1": "河南省", "admin2": "三门峡市", "note": "", "digits": "7位" } ], "0411": [ { "admin1": "辽宁省", "admin2": "大连市", "note": "", "digits": "8位" } ], "0412": [ { "admin1": "辽宁省", "admin2": "鞍山市", "note": "", "digits": "7位" } ], "0413": [ { "admin1": "辽宁省", "admin2": "抚顺市", "note": "deprecated; 2011年8月28日0时起,抚顺市使用区号24,该区号弃用。", "digits": "/" } ], "0414": [ { "admin1": "辽宁省", "admin2": "本溪市", "note": "deprecated; 2014年5月24日0时起,本溪市使用区号24,该区号弃用。", "digits": "/" } ], "0415": [ { "admin1": "辽宁省", "admin2": "丹东市", "note": "", "digits": "7位" } ], "0416": [ { "admin1": "辽宁省", "admin2": "锦州市", "note": "", "digits": "7位" } ], "0417": [ { "admin1": "辽宁省", "admin2": "营口市", "note": "", "digits": "7位" } ], "0418": [ { "admin1": "辽宁省", "admin2": "阜新市", "note": "", "digits": "7位" } ], "0419": [ { "admin1": "辽宁省", "admin2": "辽阳市", "note": "", "digits": "7位" } ], "0410": [ { "admin1": "辽宁省", "admin2": "铁岭市", "note": "deprecated; 2011年8月28日0时起,铁岭市使用区号24,该区号弃用。", "digits": "/" } ], "0421": [ { "admin1": "辽宁省", "admin2": "朝阳市", "note": "", "digits": "7位" } ], "0427": [ { "admin1": "辽宁省", "admin2": "盘锦市", "note": "", "digits": "7位" } ], "0429": [ { "admin1": "辽宁省", "admin2": "葫芦岛市", "note": "", "digits": "7位" } ], "0431": [ { "admin1": "吉林省", "admin2": "长春市", "note": "公主岭市在原七位号码前加7。", "digits": "8位" } ], "0432": [ { "admin1": "吉林省", "admin2": "吉林市", "note": "", "digits": "8位" } ], "0433": [ { "admin1": "吉林省", "admin2": "延边朝鲜族自治州", "note": "", "digits": "7位" } ], "0434": [ { "admin1": "吉林省", "admin2": "四平市", "note": "", "digits": "7位" } ], "0435": [ { "admin1": "吉林省", "admin2": "通化市", "note": "", "digits": "7位" } ], "0436": [ { "admin1": "吉林省", "admin2": "白城市", "note": "", "digits": "7位" } ], "0437": [ { "admin1": "吉林省", "admin2": "辽源市", "note": "", "digits": "7位" } ], "0438": [ { "admin1": "吉林省", "admin2": "松原市", "note": "", "digits": "7位" } ], "0439": [ { "admin1": "吉林省", "admin2": "白山市", "note": "", "digits": "7位" } ], "0448": [ { "admin1": "吉林省", "admin2": "通化市", "note": "部分地区; deprecated; 梅河口市、辉南县、柳河县现使用通化市区号435,2006年9月16日0时起,该区号弃用。", "digits": "/" } ], "0440": [ { "admin1": "吉林省", "admin2": "延边朝鲜族自治州", "district": "珲春市", "note": "deprecated; 珲春市现使用延边朝鲜族自治州区号0433,2006年9月16日0时起,该区号弃用。", "digits": "/" } ], "0451": [ { "admin1": "黑龙江省", "admin2": "哈尔滨市", "note": "", "digits": "8位" } ], "0452": [ { "admin1": "黑龙江省", "admin2": "齐齐哈尔市", "note": "", "digits": "7位" } ], "0453": [ { "admin1": "黑龙江省", "admin2": "牡丹江市", "note": "", "digits": "7位" } ], "0454": [ { "admin1": "黑龙江省", "admin2": "佳木斯市", "note": "", "digits": "7位" } ], "0455": [ { "admin1": "黑龙江省", "admin2": "绥化市", "note": "", "digits": "7位" } ], "0456": [ { "admin1": "黑龙江省", "admin2": "黑河市", "note": "", "digits": "7位" } ], "0457": [ { "admin1": "黑龙江省", "admin2": "大兴安岭地区", "note": "", "digits": "7位" } ], "0458": [ { "admin1": "黑龙江省", "admin2": "伊春市", "note": "", "digits": "7位" } ], "0459": [ { "admin1": "黑龙江省", "admin2": "大庆市", "note": "", "digits": "7位" } ], "0450": [ { "admin1": "黑龙江省", "admin2": "哈尔滨市", "district": "阿城区", "note": "deprecated; 原县级阿城市现为哈尔滨市阿城区,使用哈尔滨市区号451,该区号弃用。", "digits": "/" } ], "0464": [ { "admin1": "黑龙江省", "admin2": "七台河市", "note": "", "digits": "7位" } ], "0467": [ { "admin1": "黑龙江省", "admin2": "鸡西市", "note": "", "digits": "7位" } ], "0468": [ { "admin1": "黑龙江省", "admin2": "鹤岗市", "note": "", "digits": "7位" } ], "0469": [ { "admin1": "黑龙江省", "admin2": "双鸭山市", "note": "", "digits": "7位" } ], "0471": [ { "admin1": "内蒙古自治区", "admin2": "呼和浩特市", "note": "", "digits": "7位" } ], "0472": [ { "admin1": "内蒙古自治区", "admin2": "包头市", "note": "", "digits": "7位" } ], "0473": [ { "admin1": "内蒙古自治区", "admin2": "乌海市", "note": "", "digits": "7位" } ], "0474": [ { "admin1": "内蒙古自治区", "admin2": "乌兰察布市", "note": "", "digits": "7位" } ], "0475": [ { "admin1": "内蒙古自治区", "admin2": "通辽市", "note": "", "digits": "7位" } ], "0476": [ { "admin1": "内蒙古自治区", "admin2": "赤峰市", "note": "", "digits": "7位" } ], "0477": [ { "admin1": "内蒙古自治区", "admin2": "鄂尔多斯市", "note": "", "digits": "7位" } ], "0478": [ { "admin1": "内蒙古自治区", "admin2": "巴彦淖尔市", "note": "", "digits": "7位" } ], "0479": [ { "admin1": "内蒙古自治区", "admin2": "锡林郭勒盟", "note": "", "digits": "7位" } ], "0470": [ { "admin1": "内蒙古自治区", "admin2": "呼伦贝尔市", "note": "", "digits": "7位" } ], "0482": [ { "admin1": "内蒙古自治区", "admin2": "兴安盟", "note": "", "digits": "7位" } ], "0483": [ { "admin1": "内蒙古自治区", "admin2": "阿拉善盟", "note": "", "digits": "7位" } ], "0511": [ { "admin1": "江苏省", "admin2": "镇江市", "note": "", "digits": "8位" } ], "0512": [ { "admin1": "江苏省", "admin2": "苏州市", "note": "", "digits": "8位" } ], "0513": [ { "admin1": "江苏省", "admin2": "南通市", "note": "", "digits": "8位" } ], "0514": [ { "admin1": "江苏省", "admin2": "扬州市", "note": "", "digits": "8位" } ], "0515": [ { "admin1": "江苏省", "admin2": "盐城市", "note": "", "digits": "8位" } ], "0516": [ { "admin1": "江苏省", "admin2": "徐州市", "note": "", "digits": "8位" } ], "0517": [ { "admin1": "江苏省", "admin2": "淮安市", "note": "", "digits": "8位" } ], "0518": [ { "admin1": "江苏省", "admin2": "连云港市", "note": "", "digits": "8位" } ], "0519": [ { "admin1": "江苏省", "admin2": "常州市", "note": "", "digits": "8位" } ], "0510": [ { "admin1": "江苏省", "admin2": "无锡市", "note": "", "digits": "8位" } ], "0523": [ { "admin1": "江苏省", "admin2": "泰州市", "note": "", "digits": "8位" } ], "0527": [ { "admin1": "江苏省", "admin2": "宿迁市", "note": "", "digits": "8位" } ], "0520": [ { "admin1": "江苏省", "admin2": "苏州市", "district": "常熟市", "note": "deprecated; 2002年4月20日0时起,原吴江市、常熟市、张家港市、昆山市、太仓市与苏州市其他地区合并使用区号512,固话号码升为8位数,其中苏州市区、吴江在原号码前加6,常熟、张家港、昆山、太仓在原号码前加5。原常熟、张家港、昆山、太仓区号520弃用。", "digits": "/" }, { "admin1": "江苏省", "admin2": "苏州市", "district": "张家港市", "note": "deprecated; 2002年4月20日0时起,原吴江市、常熟市、张家港市、昆山市、太仓市与苏州市其他地区合并使用区号512,固话号码升为8位数,其中苏州市区、吴江在原号码前加6,常熟、张家港、昆山、太仓在原号码前加5。原常熟、张家港、昆山、太仓区号520弃用。", "digits": "/" }, { "admin1": "江苏省", "admin2": "苏州市", "district": "昆山市", "note": "deprecated; 2002年4月20日0时起,原吴江市、常熟市、张家港市、昆山市、太仓市与苏州市其他地区合并使用区号512,固话号码升为8位数,其中苏州市区、吴江在原号码前加6,常熟、张家港、昆山、太仓在原号码前加5。原常熟、张家港、昆山、太仓区号520弃用。", "digits": "/" }, { "admin1": "江苏省", "admin2": "苏州市", "district": "太仓市", "note": "deprecated; 2002年4月20日0时起,原吴江市、常熟市、张家港市、昆山市、太仓市与苏州市其他地区合并使用区号512,固话号码升为8位数,其中苏州市区、吴江在原号码前加6,常熟、张家港、昆山、太仓在原号码前加5。原常熟、张家港、昆山、太仓区号520弃用。", "digits": "/" } ], "0531": [ { "admin1": "山东省", "admin2": "济南市", "note": "", "digits": "8位" } ], "0532": [ { "admin1": "山东省", "admin2": "青岛市", "note": "", "digits": "8位" } ], "0533": [ { "admin1": "山东省", "admin2": "淄博市", "note": "", "digits": "7位" } ], "0534": [ { "admin1": "山东省", "admin2": "德州市", "note": "", "digits": "7位" } ], "0535": [ { "admin1": "山东省", "admin2": "烟台市", "note": "", "digits": "7位" } ], "0536": [ { "admin1": "山东省", "admin2": "潍坊市", "note": "", "digits": "7位" } ], "0537": [ { "admin1": "山东省", "admin2": "济宁市", "note": "", "digits": "7位" } ], "0538": [ { "admin1": "山东省", "admin2": "泰安市", "note": "", "digits": "7位" } ], "0539": [ { "admin1": "山东省", "admin2": "临沂市", "note": "", "digits": "7位" } ], "0530": [ { "admin1": "山东省", "admin2": "菏泽市", "note": "", "digits": "7位" } ], "0543": [ { "admin1": "山东省", "admin2": "滨州市", "note": "", "digits": "7位" } ], "0546": [ { "admin1": "山东省", "admin2": "东营市", "note": "", "digits": "7位" } ], "0551": [ { "admin1": "安徽省", "admin2": "合肥市", "note": "", "digits": "8位" } ], "0552": [ { "admin1": "安徽省", "admin2": "蚌埠市", "note": "", "digits": "7位" } ], "0553": [ { "admin1": "安徽省", "admin2": "芜湖市", "note": "", "digits": "7位" } ], "0554": [ { "admin1": "安徽省", "admin2": "淮南市", "note": "", "digits": "7位" } ], "0555": [ { "admin1": "安徽省", "admin2": "马鞍山市", "note": "", "digits": "7位" } ], "0556": [ { "admin1": "安徽省", "admin2": "安庆市", "note": "", "digits": "7位" } ], "0557": [ { "admin1": "安徽省", "admin2": "宿州市", "note": "", "digits": "7位" } ], "0558": [ { "admin1": "安徽省", "admin2": "阜阳市", "note": "", "digits": "7位" }, { "admin1": "安徽省", "admin2": "亳州市", "note": "", "digits": "7位" } ], "0559": [ { "admin1": "安徽省", "admin2": "黄山市", "note": "", "digits": "7位" } ], "0550": [ { "admin1": "安徽省", "admin2": "滁州市", "note": "", "digits": "7位" } ], "0561": [ { "admin1": "安徽省", "admin2": "淮北市", "note": "", "digits": "7位" } ], "0562": [ { "admin1": "安徽省", "admin2": "铜陵市", "note": "", "digits": "7位" } ], "0563": [ { "admin1": "安徽省", "admin2": "宣城市", "note": "", "digits": "7位" } ], "0564": [ { "admin1": "安徽省", "admin2": "六安市", "note": "", "digits": "7位" } ], "0565": [ { "admin1": "安徽省", "admin2": "合肥市", "district": "巢湖市", "note": "地级; deprecated; 地级巢湖市已被撤销,该区号弃用。2012年12月8日0时起,原居巢区、庐江县使用合肥市区号551;2011年12月10日0时起,原无为县、和县沈巷镇使用芜湖市区号553;2011年12月17日0时起,原和县其它地区、含山县使用马鞍山市区号555。", "digits": "/" } ], "0566": [ { "admin1": "安徽省", "admin2": "池州市", "note": "", "digits": "7位" } ], "0571": [ { "admin1": "浙江省", "admin2": "杭州市", "note": "", "digits": "8位" } ], "0572": [ { "admin1": "浙江省", "admin2": "湖州市", "note": "", "digits": "7位" } ], "0573": [ { "admin1": "浙江省", "admin2": "嘉兴市", "note": "", "digits": "8位" } ], "0574": [ { "admin1": "浙江省", "admin2": "宁波市", "note": "", "digits": "8位" } ], "0575": [ { "admin1": "浙江省", "admin2": "绍兴市", "note": "", "digits": "8位" } ], "0576": [ { "admin1": "浙江省", "admin2": "台州市", "note": "", "digits": "8位" } ], "0577": [ { "admin1": "浙江省", "admin2": "温州市", "note": "", "digits": "8位" } ], "0578": [ { "admin1": "浙江省", "admin2": "丽水市", "note": "", "digits": "7位" } ], "0579": [ { "admin1": "浙江省", "admin2": "金华市", "note": "", "digits": "8位" } ], "0570": [ { "admin1": "浙江省", "admin2": "衢州市", "note": "", "digits": "7位" } ], "0580": [ { "admin1": "浙江省", "admin2": "舟山市", "note": "", "digits": "7位" } ], "0591": [ { "admin1": "福建省", "admin2": "福州市", "note": "", "digits": "8位" } ], "0592": [ { "admin1": "福建省", "admin2": "厦门市", "note": "", "digits": "7位" } ], "0593": [ { "admin1": "福建省", "admin2": "宁德市", "note": "", "digits": "7位" } ], "0594": [ { "admin1": "福建省", "admin2": "莆田市", "note": "", "digits": "7位" } ], "0595": [ { "admin1": "福建省", "admin2": "泉州市", "note": "", "digits": "8位" } ], "0596": [ { "admin1": "福建省", "admin2": "漳州市", "note": "", "digits": "7位" } ], "0597": [ { "admin1": "福建省", "admin2": "龙岩市", "note": "", "digits": "7位" } ], "0598": [ { "admin1": "福建省", "admin2": "三明市", "note": "", "digits": "7位" } ], "0599": [ { "admin1": "福建省", "admin2": "南平市", "note": "", "digits": "7位" } ], "0631": [ { "admin1": "山东省", "admin2": "威海市", "note": "", "digits": "7位" } ], "0632": [ { "admin1": "山东省", "admin2": "枣庄市", "note": "", "digits": "7位" } ], "0633": [ { "admin1": "山东省", "admin2": "日照市", "note": "", "digits": "7位" } ], "0634": [ { "admin1": "山东省", "admin2": "济南市", "district": "莱芜区", "note": "deprecated; 莱芜市已被撤销,该区号弃用,2020年4月25日0时起,其境内的固定电话使用济南市区号531,并在号码前加“7”,号码位数由7位升为8位。", "digits": "/" } ], "0635": [ { "admin1": "山东省", "admin2": "聊城市", "note": "", "digits": "7位" } ], "0661": [ { "admin1": "广东省", "admin2": "汕头市", "district": "潮阳区", "note": "deprecated; 潮阳市已被撤销,该区号弃用,2004年6月26日0时起,其境内的固定电话使用汕头市区号754,并在号码前加“8”,号码位数由7位升为8位。", "digits": "/" } ], "0662": [ { "admin1": "广东省", "admin2": "阳江市", "note": "", "digits": "7位" } ], "0663": [ { "admin1": "广东省", "admin2": "揭阳市", "note": "", "digits": "7位" } ], "0668": [ { "admin1": "广东省", "admin2": "茂名市", "note": "", "digits": "7位" } ], "0660": [ { "admin1": "广东省", "admin2": "汕尾市", "note": "东沙群岛实际在中华民国政府的控制范围内,因此使用台湾的国际电话区号+886并由高雄市当局分配电话号码,号码格式为+886 7 2771xxx或+886 7 2781xxx。", "digits": "7位" } ], "0691": [ { "admin1": "云南省", "admin2": "西双版纳傣族自治州", "note": "", "digits": "7位" } ], "0692": [ { "admin1": "云南省", "admin2": "德宏傣族景颇族自治州", "note": "", "digits": "7位" } ], "0711": [ { "admin1": "湖北省", "admin2": "鄂州市", "district": "鄂城区", "note": "", "digits": "7位" }, { "admin1": "湖北省", "admin2": "鄂州市", "district": "梁子湖区", "note": "", "digits": "7位" } ], "0712": [ { "admin1": "湖北省", "admin2": "孝感市", "note": "", "digits": "7位" } ], "0713": [ { "admin1": "湖北省", "admin2": "黄冈市", "note": "", "digits": "7位" } ], "0714": [ { "admin1": "湖北省", "admin2": "黄石市", "note": "", "digits": "7位" } ], "0715": [ { "admin1": "湖北省", "admin2": "咸宁市", "note": "", "digits": "7位" } ], "0716": [ { "admin1": "湖北省", "admin2": "荆州市", "note": "", "digits": "7位" } ], "0717": [ { "admin1": "湖北省", "admin2": "宜昌市", "note": "", "digits": "7位" } ], "0718": [ { "admin1": "湖北省", "admin2": "恩施土家族苗族自治州", "note": "", "digits": "7位" } ], "0719": [ { "admin1": "湖北省", "admin2": "十堰市", "note": "", "digits": "7位" }, { "admin1": "湖北省", "district": "神农架林区", "note": "", "digits": "7位" } ], "0710": [ { "admin1": "湖北省", "admin2": "襄阳市", "note": "", "digits": "7位" } ], "0722": [ { "admin1": "湖北省", "admin2": "随州市", "note": "", "digits": "7位" } ], "0724": [ { "admin1": "湖北省", "admin2": "荆门市", "note": "", "digits": "7位" } ], "0728": [ { "admin1": "湖北省", "district": "仙桃市", "note": "", "digits": "7位" }, { "admin1": "湖北省", "district": "天门市", "note": "", "digits": "7位" }, { "admin1": "湖北省", "district": "潜江市", "note": "", "digits": "7位" } ], "0731": [ { "admin1": "湖南省", "admin2": "长沙市", "note": "", "digits": "8位" }, { "admin1": "湖南省", "admin2": "湘潭市", "note": "", "digits": "8位" }, { "admin1": "湖南省", "admin2": "株洲市", "note": "", "digits": "8位" } ], "0732": [ { "admin1": "湖南省", "admin2": "湘潭市", "note": "deprecated; 2009年6月28日0时起,湘潭市和株洲市不再使用单独区号,与长沙市共同使用区号731;原区号弃用。", "digits": "/" } ], "0733": [ { "admin1": "湖南省", "admin2": "株洲市", "note": "deprecated; 2009年6月28日0时起,湘潭市和株洲市不再使用单独区号,与长沙市共同使用区号731;原区号弃用。", "digits": "/" } ], "0734": [ { "admin1": "湖南省", "admin2": "衡阳市", "note": "", "digits": "7位" } ], "0735": [ { "admin1": "湖南省", "admin2": "郴州市", "note": "", "digits": "7位" } ], "0736": [ { "admin1": "湖南省", "admin2": "常德市", "note": "", "digits": "7位" } ], "0737": [ { "admin1": "湖南省", "admin2": "益阳市", "note": "", "digits": "7位" } ], "0738": [ { "admin1": "湖南省", "admin2": "娄底市", "note": "", "digits": "7位" } ], "0739": [ { "admin1": "湖南省", "admin2": "邵阳市", "note": "", "digits": "7位" } ], "0730": [ { "admin1": "湖南省", "admin2": "岳阳市", "note": "", "digits": "7位" } ], "0743": [ { "admin1": "湖南省", "admin2": "湘西土家族苗族自治州", "note": "", "digits": "7位" } ], "0744": [ { "admin1": "湖南省", "admin2": "张家界市", "note": "", "digits": "7位" } ], "0745": [ { "admin1": "湖南省", "admin2": "怀化市", "note": "", "digits": "7位" } ], "0746": [ { "admin1": "湖南省", "admin2": "永州市", "note": "", "digits": "7位" } ], "0751": [ { "admin1": "广东省", "admin2": "韶关市", "note": "", "digits": "7位" } ], "0752": [ { "admin1": "广东省", "admin2": "惠州市", "note": "", "digits": "7位" } ], "0753": [ { "admin1": "广东省", "admin2": "梅州市", "note": "", "digits": "7位" } ], "0754": [ { "admin1": "广东省", "admin2": "汕头市", "note": "", "digits": "8位" } ], "0755": [ { "admin1": "广东省", "admin2": "深圳市", "note": "", "digits": "8位" } ], "0756": [ { "admin1": "广东省", "admin2": "珠海市", "note": "", "digits": "7位" } ], "0757": [ { "admin1": "广东省", "admin2": "佛山市", "note": "", "digits": "8位" } ], "0758": [ { "admin1": "广东省", "admin2": "肇庆市", "note": "", "digits": "7位" } ], "0759": [ { "admin1": "广东省", "admin2": "湛江市", "note": "", "digits": "7位" } ], "0750": [ { "admin1": "广东省", "admin2": "江门市", "note": "", "digits": "7位" } ], "0762": [ { "admin1": "广东省", "admin2": "河源市", "note": "", "digits": "7位" } ], "0763": [ { "admin1": "广东省", "admin2": "清远市", "note": "", "digits": "7位" } ], "0765": [ { "admin1": "广东省", "admin2": "佛山市", "district": "顺德区", "note": "deprecated; 顺德市已被撤销,该区号弃用,2003年12月6日0时起,与佛山市合并使用区号757。", "digits": "/" } ], "0766": [ { "admin1": "广东省", "admin2": "云浮市", "note": "", "digits": "7位" } ], "0768": [ { "admin1": "广东省", "admin2": "潮州市", "note": "", "digits": "7位" } ], "0769": [ { "admin1": "广东省", "admin2": "东莞市", "note": "", "digits": "8位" } ], "0760": [ { "admin1": "广东省", "admin2": "中山市", "note": "", "digits": "8位" } ], "0771": [ { "admin1": "广西壮族自治区", "admin2": "南宁市", "note": "", "digits": "7位" }, { "admin1": "广西壮族自治区", "admin2": "崇左市", "note": "", "digits": "7位" } ], "0772": [ { "admin1": "广西壮族自治区", "admin2": "柳州市", "note": "", "digits": "7位" }, { "admin1": "广西壮族自治区", "admin2": "来宾市", "note": "", "digits": "7位" } ], "0773": [ { "admin1": "广西壮族自治区", "admin2": "桂林市", "note": "", "digits": "7位" } ], "0774": [ { "admin1": "广西壮族自治区", "admin2": "梧州市", "note": "", "digits": "7位" }, { "admin1": "广西壮族自治区", "admin2": "贺州市", "note": "", "digits": "7位" } ], "0775": [ { "admin1": "广西壮族自治区", "admin2": "贵港市", "note": "", "digits": "7位" }, { "admin1": "广西壮族自治区", "admin2": "玉林市", "note": "", "digits": "7位" } ], "0776": [ { "admin1": "广西壮族自治区", "admin2": "百色市", "note": "", "digits": "7位" } ], "0777": [ { "admin1": "广西壮族自治区", "admin2": "钦州市", "note": "", "digits": "7位" } ], "0778": [ { "admin1": "广西壮族自治区", "admin2": "河池市", "note": "", "digits": "7位" } ], "0779": [ { "admin1": "广西壮族自治区", "admin2": "北海市", "note": "", "digits": "7位" } ], "0770": [ { "admin1": "广西壮族自治区", "admin2": "防城港市", "note": "", "digits": "7位" } ], "0791": [ { "admin1": "江西省", "admin2": "南昌市", "note": "", "digits": "8位" } ], "0792": [ { "admin1": "江西省", "admin2": "九江市", "note": "", "digits": "7位" } ], "0793": [ { "admin1": "江西省", "admin2": "上饶市", "note": "", "digits": "7位" } ], "0794": [ { "admin1": "江西省", "admin2": "抚州市", "note": "", "digits": "7位" } ], "0795": [ { "admin1": "江西省", "admin2": "宜春市", "note": "", "digits": "7位" } ], "0796": [ { "admin1": "江西省", "admin2": "吉安市", "note": "", "digits": "7位" } ], "0797": [ { "admin1": "江西省", "admin2": "赣州市", "note": "", "digits": "7位" } ], "0798": [ { "admin1": "江西省", "admin2": "景德镇市", "note": "", "digits": "7位" } ], "0799": [ { "admin1": "江西省", "admin2": "萍乡市", "note": "", "digits": "7位" } ], "0790": [ { "admin1": "江西省", "admin2": "新余市", "note": "", "digits": "7位" } ], "0701": [ { "admin1": "江西省", "admin2": "鹰潭市", "note": "", "digits": "7位" } ], "0811": [ { "admin1": "四川省", "note": "重庆市(主城区); deprecated; 1997年重庆成为直辖市后使用区号23,1997年8月9日0时起,该区号弃用。", "digits": "/" } ], "0812": [ { "admin1": "四川省", "admin2": "攀枝花市", "note": "", "digits": "7位" } ], "0813": [ { "admin1": "四川省", "admin2": "自贡市", "note": "", "digits": "7位" } ], "0814": [ { "admin1": "四川省", "note": "永川地区; deprecated; 1983年永川地区并入重庆省辖市后继续使用区号814,1997年重庆成为直辖市后使用区号23,1997年8月9日0时起,该区号弃用。", "digits": "/" } ], "0815": [ { "admin1": "四川省", "note": "温江地区; deprecated; 1983年温江地区并入成都市后使用区号28,该区号弃用。", "digits": "/" } ], "0816": [ { "admin1": "四川省", "admin2": "绵阳市", "note": "", "digits": "7位" } ], "0817": [ { "admin1": "四川省", "admin2": "南充市", "note": "", "digits": "7位" } ], "0818": [ { "admin1": "四川省", "admin2": "达州市", "note": "", "digits": "7位" } ], "0819": [ { "admin1": "四川省", "note": "万县市(地级); deprecated; 1997年万县市并入重庆直辖市后使用区号23,1998年11月28日0时起,该区号弃用。", "digits": "/" } ], "0810": [ { "admin1": "四川省", "note": "涪陵市(地级); deprecated; 1997年涪陵市并入重庆直辖市后使用区号23,1998年11月28日0时起,该区号弃用。", "digits": "/" } ], "0825": [ { "admin1": "四川省", "admin2": "遂宁市", "note": "", "digits": "7位" } ], "0826": [ { "admin1": "四川省", "admin2": "广安市", "note": "", "digits": "7位" } ], "0827": [ { "admin1": "四川省", "admin2": "巴中市", "note": "", "digits": "7位" } ], "0829": [ { "admin1": "四川省", "note": "黔江地区; deprecated; 1997年黔江地区并入重庆直辖市后使用区号23,1998年11月28日0时起,该区号弃用。", "digits": "/" } ], "0831": [ { "admin1": "四川省", "admin2": "宜宾市", "note": "", "digits": "7位" } ], "0832": [ { "admin1": "四川省", "admin2": "内江市", "note": "内江市和资阳市原本同属内江地区,皆使用区号832,2010年1月31日0时起资阳市使用区号28,该区号现仅由内江市使用。", "digits": "7位" } ], "0833": [ { "admin1": "四川省", "admin2": "乐山市", "note": "乐山市和眉山市原本同属乐山地区,皆使用区号833,2010年1月31日0时起眉山市使用区号28,该区号现仅由乐山市使用。", "digits": "7位" } ], "0834": [ { "admin1": "四川省", "admin2": "凉山彝族自治州", "note": "", "digits": "7位" } ], "0835": [ { "admin1": "四川省", "admin2": "雅安市", "note": "", "digits": "7位" } ], "0836": [ { "admin1": "四川省", "admin2": "甘孜藏族自治州", "note": "", "digits": "7位" } ], "0837": [ { "admin1": "四川省", "admin2": "阿坝藏族羌族自治州", "note": "", "digits": "7位" } ], "0838": [ { "admin1": "四川省", "admin2": "德阳市", "note": "", "digits": "7位" } ], "0839": [ { "admin1": "四川省", "admin2": "广元市", "note": "", "digits": "7位" } ], "0830": [ { "admin1": "四川省", "admin2": "泸州市", "note": "", "digits": "7位" } ], "0851": [ { "admin1": "贵州省", "admin2": "贵阳市", "note": "", "digits": "8位" }, { "admin1": "贵州省", "admin2": "遵义市", "note": "", "digits": "8位" }, { "admin1": "贵州省", "admin2": "安顺市", "note": "", "digits": "8位" } ], "0852": [ { "admin1": "贵州省", "admin2": "遵义市", "note": "deprecated; 2014年12月20日0时起,遵义市使用区号851,该区号弃用。", "digits": "/" } ], "0853": [ { "admin1": "贵州省", "admin2": "安顺市", "note": "deprecated; 2014年12月20日0时起,安顺市使用区号851,该区号弃用。", "digits": "/" } ], "0854": [ { "admin1": "贵州省", "admin2": "黔南布依族苗族自治州", "note": "", "digits": "7位" } ], "0855": [ { "admin1": "贵州省", "admin2": "黔东南苗族侗族自治州", "note": "", "digits": "7位" } ], "0856": [ { "admin1": "贵州省", "admin2": "铜仁市", "note": "", "digits": "7位" } ], "0857": [ { "admin1": "贵州省", "admin2": "毕节市", "note": "", "digits": "7位" } ], "0858": [ { "admin1": "贵州省", "admin2": "六盘水市", "note": "", "digits": "7位" } ], "0859": [ { "admin1": "贵州省", "admin2": "黔西南布依族苗族自治州", "note": "", "digits": "7位" } ], "0871": [ { "admin1": "云南省", "admin2": "昆明市", "note": "", "digits": "8位" } ], "0872": [ { "admin1": "云南省", "admin2": "大理白族自治州", "note": "", "digits": "7位" } ], "0873": [ { "admin1": "云南省", "admin2": "红河哈尼族彝族自治州", "note": "", "digits": "7位" } ], "0874": [ { "admin1": "云南省", "admin2": "曲靖市", "note": "", "digits": "7位" } ], "0875": [ { "admin1": "云南省", "admin2": "保山市", "note": "", "digits": "7位" } ], "0876": [ { "admin1": "云南省", "admin2": "文山壮族苗族自治州", "note": "", "digits": "7位" } ], "0877": [ { "admin1": "云南省", "admin2": "玉溪市", "note": "", "digits": "7位" } ], "0878": [ { "admin1": "云南省", "admin2": "楚雄彝族自治州", "note": "", "digits": "7位" } ], "0879": [ { "admin1": "云南省", "admin2": "普洱市", "note": "缅甸佤邦部分电话事实上也使用该区号。", "digits": "7位" } ], "0870": [ { "admin1": "云南省", "admin2": "昭通市", "note": "", "digits": "7位" } ], "0881": [ { "admin1": "云南省", "admin2": "昆明市", "district": "东川区", "note": "deprecated; 东川市已被撤销,该区号弃用,其境内的固定电话使用昆明市区号871。", "digits": "/" } ], "0883": [ { "admin1": "云南省", "admin2": "临沧市", "note": "缅甸联邦第一特区部分电话事实上也使用该区号。", "digits": "7位" } ], "0886": [ { "admin1": "云南省", "admin2": "怒江傈僳族自治州", "note": "", "digits": "7位" } ], "0887": [ { "admin1": "云南省", "admin2": "迪庆藏族自治州", "note": "", "digits": "7位" } ], "0888": [ { "admin1": "云南省", "admin2": "丽江市", "note": "", "digits": "7位" } ], "0891": [ { "admin1": "西藏自治区", "admin2": "拉萨市", "note": "", "digits": "7位" } ], "0892": [ { "admin1": "西藏自治区", "admin2": "日喀则市", "note": "", "digits": "7位" } ], "0893": [ { "admin1": "西藏自治区", "admin2": "山南市", "note": "", "digits": "7位" } ], "0894": [ { "admin1": "西藏自治区", "admin2": "林芝市", "note": "", "digits": "7位" } ], "0895": [ { "admin1": "西藏自治区", "admin2": "昌都市", "note": "", "digits": "7位" } ], "0896": [ { "admin1": "西藏自治区", "admin2": "那曲市", "note": "", "digits": "7位" } ], "0897": [ { "admin1": "西藏自治区", "admin2": "阿里地区", "note": "", "digits": "7位" } ], "0898": [ { "admin1": "海南省", "note": "全境; 海南省原有海口本地网(区号898)、三亚本地网(区号899)和儋州本地网(区号890),各本地网主要覆盖对应城市以及周边县市。2001年5月18日0时起,海南全省使用统一区号898,区号899和890弃用。统一区号后,原海口本地网的号码在首位加6,原三亚本地网的号码在首位加8,原儋州本地网的号码在首位加2,通过该规则海南全省的电话号码由7位升至8位。", "digits": "8位" } ], "0899": [ { "admin1": "海南省", "admin2": "三亚市", "note": "deprecated; 海南省原有海口本地网(区号898)、三亚本地网(区号899)和儋州本地网(区号890),各本地网主要覆盖对应城市以及周边县市。2001年5月18日0时起,海南全省使用统一区号898,区号899和890弃用。统一区号后,原海口本地网的号码在首位加6,原三亚本地网的号码在首位加8,原儋州本地网的号码在首位加2,通过该规则海南全省的电话号码由7位升至8位。", "digits": "/" }, { "admin1": "海南省", "district": "五指山市", "note": "deprecated; 海南省原有海口本地网(区号898)、三亚本地网(区号899)和儋州本地网(区号890),各本地网主要覆盖对应城市以及周边县市。2001年5月18日0时起,海南全省使用统一区号898,区号899和890弃用。统一区号后,原海口本地网的号码在首位加6,原三亚本地网的号码在首位加8,原儋州本地网的号码在首位加2,通过该规则海南全省的电话号码由7位升至8位。", "digits": "/" }, { "admin1": "海南省", "district": "陵水黎族自治县", "note": "deprecated; 海南省原有海口本地网(区号898)、三亚本地网(区号899)和儋州本地网(区号890),各本地网主要覆盖对应城市以及周边县市。2001年5月18日0时起,海南全省使用统一区号898,区号899和890弃用。统一区号后,原海口本地网的号码在首位加6,原三亚本地网的号码在首位加8,原儋州本地网的号码在首位加2,通过该规则海南全省的电话号码由7位升至8位。", "digits": "/" }, { "admin1": "海南省", "district": "乐东黎族自治县", "note": "deprecated; 海南省原有海口本地网(区号898)、三亚本地网(区号899)和儋州本地网(区号890),各本地网主要覆盖对应城市以及周边县市。2001年5月18日0时起,海南全省使用统一区号898,区号899和890弃用。统一区号后,原海口本地网的号码在首位加6,原三亚本地网的号码在首位加8,原儋州本地网的号码在首位加2,通过该规则海南全省的电话号码由7位升至8位。", "digits": "/" }, { "admin1": "海南省", "district": "保亭黎族苗族自治县", "note": "deprecated; 海南省原有海口本地网(区号898)、三亚本地网(区号899)和儋州本地网(区号890),各本地网主要覆盖对应城市以及周边县市。2001年5月18日0时起,海南全省使用统一区号898,区号899和890弃用。统一区号后,原海口本地网的号码在首位加6,原三亚本地网的号码在首位加8,原儋州本地网的号码在首位加2,通过该规则海南全省的电话号码由7位升至8位。", "digits": "/" }, { "admin1": "海南省", "district": "琼中黎族苗族自治县", "note": "deprecated; 海南省原有海口本地网(区号898)、三亚本地网(区号899)和儋州本地网(区号890),各本地网主要覆盖对应城市以及周边县市。2001年5月18日0时起,海南全省使用统一区号898,区号899和890弃用。统一区号后,原海口本地网的号码在首位加6,原三亚本地网的号码在首位加8,原儋州本地网的号码在首位加2,通过该规则海南全省的电话号码由7位升至8位。", "digits": "/" } ], "0890": [ { "admin1": "海南省", "admin2": "儋州市", "note": "deprecated; 海南省原有海口本地网(区号898)、三亚本地网(区号899)和儋州本地网(区号890),各本地网主要覆盖对应城市以及周边县市。2001年5月18日0时起,海南全省使用统一区号898,区号899和890弃用。统一区号后,原海口本地网的号码在首位加6,原三亚本地网的号码在首位加8,原儋州本地网的号码在首位加2,通过该规则海南全省的电话号码由7位升至8位。", "digits": "/" }, { "admin1": "海南省", "district": "东方市", "note": "deprecated; 海南省原有海口本地网(区号898)、三亚本地网(区号899)和儋州本地网(区号890),各本地网主要覆盖对应城市以及周边县市。2001年5月18日0时起,海南全省使用统一区号898,区号899和890弃用。统一区号后,原海口本地网的号码在首位加6,原三亚本地网的号码在首位加8,原儋州本地网的号码在首位加2,通过该规则海南全省的电话号码由7位升至8位。", "digits": "/" }, { "admin1": "海南省", "district": "临高县", "note": "deprecated; 海南省原有海口本地网(区号898)、三亚本地网(区号899)和儋州本地网(区号890),各本地网主要覆盖对应城市以及周边县市。2001年5月18日0时起,海南全省使用统一区号898,区号899和890弃用。统一区号后,原海口本地网的号码在首位加6,原三亚本地网的号码在首位加8,原儋州本地网的号码在首位加2,通过该规则海南全省的电话号码由7位升至8位。", "digits": "/" }, { "admin1": "海南省", "district": "白沙黎族自治县", "note": "deprecated; 海南省原有海口本地网(区号898)、三亚本地网(区号899)和儋州本地网(区号890),各本地网主要覆盖对应城市以及周边县市。2001年5月18日0时起,海南全省使用统一区号898,区号899和890弃用。统一区号后,原海口本地网的号码在首位加6,原三亚本地网的号码在首位加8,原儋州本地网的号码在首位加2,通过该规则海南全省的电话号码由7位升至8位。", "digits": "/" }, { "admin1": "海南省", "district": "昌江黎族自治县", "note": "deprecated; 海南省原有海口本地网(区号898)、三亚本地网(区号899)和儋州本地网(区号890),各本地网主要覆盖对应城市以及周边县市。2001年5月18日0时起,海南全省使用统一区号898,区号899和890弃用。统一区号后,原海口本地网的号码在首位加6,原三亚本地网的号码在首位加8,原儋州本地网的号码在首位加2,通过该规则海南全省的电话号码由7位升至8位。", "digits": "/" } ], "0911": [ { "admin1": "陕西省", "admin2": "延安市", "note": "", "digits": "7位" } ], "0912": [ { "admin1": "陕西省", "admin2": "榆林市", "note": "", "digits": "7位" } ], "0913": [ { "admin1": "陕西省", "admin2": "渭南市", "note": "", "digits": "7位" } ], "0914": [ { "admin1": "陕西省", "admin2": "商洛市", "note": "", "digits": "7位" } ], "0915": [ { "admin1": "陕西省", "admin2": "安康市", "note": "", "digits": "7位" } ], "0916": [ { "admin1": "陕西省", "admin2": "汉中市", "note": "", "digits": "7位" } ], "0917": [ { "admin1": "陕西省", "admin2": "宝鸡市", "note": "", "digits": "7位" } ], "0919": [ { "admin1": "陕西省", "admin2": "铜川市", "note": "", "digits": "7位" } ], "0910": [ { "admin1": "陕西省", "admin2": "咸阳市", "note": "deprecated; 2006年9月16日0时起,咸阳市与西安市合并使用区号29,该区号弃用。", "digits": "/" } ], "0931": [ { "admin1": "甘肃省", "admin2": "兰州市", "note": "", "digits": "7位" } ], "0932": [ { "admin1": "甘肃省", "admin2": "定西市", "note": "", "digits": "7位" } ], "0933": [ { "admin1": "甘肃省", "admin2": "平凉市", "note": "", "digits": "7位" } ], "0934": [ { "admin1": "甘肃省", "admin2": "庆阳市", "note": "", "digits": "7位" } ], "0935": [ { "admin1": "甘肃省", "admin2": "武威市", "note": "", "digits": "7位" }, { "admin1": "甘肃省", "admin2": "金昌市", "note": "", "digits": "7位" } ], "0936": [ { "admin1": "甘肃省", "admin2": "张掖市", "note": "", "digits": "7位" } ], "0937": [ { "admin1": "甘肃省", "admin2": "酒泉市", "note": "", "digits": "7位" }, { "admin1": "甘肃省", "admin2": "嘉峪关市", "note": "", "digits": "7位" } ], "0938": [ { "admin1": "甘肃省", "admin2": "天水市", "note": "", "digits": "7位" } ], "0939": [ { "admin1": "甘肃省", "admin2": "陇南市", "note": "", "digits": "7位" } ], "0930": [ { "admin1": "甘肃省", "admin2": "临夏回族自治州", "note": "", "digits": "7位" } ], "0941": [ { "admin1": "甘肃省", "admin2": "甘南藏族自治州", "note": "", "digits": "7位" } ], "0943": [ { "admin1": "甘肃省", "admin2": "白银市", "note": "", "digits": "7位" } ], "0951": [ { "admin1": "宁夏回族自治区", "admin2": "银川市", "note": "", "digits": "7位" } ], "0952": [ { "admin1": "宁夏回族自治区", "admin2": "石嘴山市", "note": "", "digits": "7位" } ], "0953": [ { "admin1": "宁夏回族自治区", "admin2": "吴忠市", "note": "", "digits": "7位" } ], "0954": [ { "admin1": "宁夏回族自治区", "admin2": "固原市", "note": "", "digits": "7位" } ], "0955": [ { "admin1": "宁夏回族自治区", "admin2": "中卫市", "note": "", "digits": "7位" } ], "0971": [ { "admin1": "青海省", "admin2": "西宁市", "note": "", "digits": "7位" } ], "0972": [ { "admin1": "青海省", "admin2": "海东市", "note": "", "digits": "7位" } ], "0973": [ { "admin1": "青海省", "admin2": "黄南藏族自治州", "note": "", "digits": "7位" } ], "0974": [ { "admin1": "青海省", "admin2": "海南藏族自治州", "note": "", "digits": "7位" } ], "0975": [ { "admin1": "青海省", "admin2": "果洛藏族自治州", "note": "", "digits": "7位" } ], "0976": [ { "admin1": "青海省", "admin2": "玉树藏族自治州", "note": "", "digits": "7位" } ], "0977": [ { "admin1": "青海省", "admin2": "海西蒙古族藏族自治州", "note": "不含格尔木市", "digits": "7位" } ], "0978": [ { "admin1": "青海省", "admin2": "海北藏族自治州", "district": "门源回族自治县", "note": "deprecated; 门源回族自治县已并入海北藏族自治州,使用区号970,该区号弃用。", "digits": "/" } ], "0979": [ { "admin1": "青海省", "admin2": "海西蒙古族藏族自治州", "district": "格尔木市", "note": "", "digits": "7位" } ], "0970": [ { "admin1": "青海省", "admin2": "海北藏族自治州", "note": "", "digits": "7位" } ], "0991": [ { "admin1": "新疆维吾尔自治区", "admin2": "乌鲁木齐市", "note": "", "digits": "7位" } ], "0992": [ { "admin1": "新疆维吾尔自治区", "admin2": "伊犁哈萨克自治州", "district": "奎屯市", "note": "", "digits": "7位" }, { "admin1": "新疆维吾尔自治区", "admin2": "塔城地区", "district": "乌苏市", "note": "", "digits": "7位" }, { "admin1": "新疆维吾尔自治区", "admin2": "克拉玛依市", "district": "独山子区", "note": "", "digits": "7位" }, { "admin1": "新疆维吾尔自治区", "district": "胡杨河市", "note": "", "digits": "7位" } ], "0993": [ { "admin1": "新疆维吾尔自治区", "district": "石河子市", "note": "", "digits": "7位" }, { "admin1": "新疆维吾尔自治区", "admin2": "塔城地区", "district": "沙湾市", "note": "", "digits": "7位" } ], "0994": [ { "admin1": "新疆维吾尔自治区", "admin2": "昌吉回族自治州", "note": "", "digits": "7位" }, { "admin1": "新疆维吾尔自治区", "district": "五家渠市", "note": "", "digits": "7位" } ], "0995": [ { "admin1": "新疆维吾尔自治区", "admin2": "吐鲁番市", "note": "", "digits": "7位" } ], "0996": [ { "admin1": "新疆维吾尔自治区", "admin2": "巴音郭楞蒙古自治州", "note": "", "digits": "7位" }, { "admin1": "新疆维吾尔自治区", "district": "铁门关市", "note": "", "digits": "7位" } ], "0997": [ { "admin1": "新疆维吾尔自治区", "admin2": "阿克苏地区", "note": "", "digits": "7位" }, { "admin1": "新疆维吾尔自治区", "district": "阿拉尔市", "note": "", "digits": "7位" } ], "0998": [ { "admin1": "新疆维吾尔自治区", "admin2": "喀什地区", "note": "", "digits": "7位" }, { "admin1": "新疆维吾尔自治区", "district": "图木舒克市", "note": "", "digits": "7位" }, { "admin1": "新疆维吾尔自治区", "note": "草湖市", "digits": "7位" } -
plates.json 45.5 KB
{ "_meta": { "kind": "plate", "normalize": { "strip": " ·-", "upper": true }, "key_pattern": "^[\\u4e00-\\u9fff][A-Z]?", "usage": "first Han character (province) + issuing letter, e.g. <abbr><letter>; the rest of the plate is ignored", "note": "Municipality letter splits come from common knowledge, marked unverified; Hong Kong/Macau/Taiwan, military and police plates are not in the table.", "miss_note": "province abbreviation not in the table", "source": [ "https://zh.wikipedia.org/zh-cn/中华人民共和国民用机动车号牌" ], "fetched": "2026-09-14", "license": "Derived from Wikipedia, CC BY-SA 4.0", "count": 501 }, "entries": { "京": [ { "admin1": "北京市", "note": "province abbreviation only" } ], "京A": [ { "admin1": "北京市", "note": "市区普通汽车; 中央国家机关用车; 外资机构用车; 公交车; 新能源汽车" } ], "京B": [ { "admin1": "北京市", "note": "出租车" } ], "京C": [ { "admin1": "北京市", "note": "市区普通汽车" } ], "京E": [ { "admin1": "北京市", "note": "市区普通汽车" } ], "京F": [ { "admin1": "北京市", "note": "市区普通汽车" } ], "京H": [ { "admin1": "北京市", "note": "市区普通汽车" } ], "京J": [ { "admin1": "北京市", "note": "市区普通汽车" } ], "京K": [ { "admin1": "北京市", "note": "市区普通汽车" } ], "京L": [ { "admin1": "北京市", "note": "市区普通汽车" } ], "京M": [ { "admin1": "北京市", "note": "市区普通汽车" } ], "京N": [ { "admin1": "北京市", "note": "市区普通汽车" } ], "京P": [ { "admin1": "北京市", "note": "市区普通汽车" } ], "京Q": [ { "admin1": "北京市", "note": "市区普通汽车" } ], "京D": [ { "admin1": "北京市", "note": "警用" } ], "京G": [ { "admin1": "北京市", "note": "郊区普通汽车" } ], "京Y": [ { "admin1": "北京市", "note": "郊区普通汽车" } ], "京R": [ { "admin1": "北京市", "note": "临时号牌" } ], "京S": [ { "admin1": "北京市", "note": "临时号牌" } ], "京T": [ { "admin1": "北京市", "note": "临时号牌" } ], "京U": [ { "admin1": "北京市", "note": "临时号牌" } ], "京W": [ { "admin1": "北京市", "note": "临时号牌" } ], "京X": [ { "admin1": "北京市", "note": "临时号牌" } ], "京Z": [ { "admin1": "北京市", "note": "临时号牌" } ], "京V": [ { "admin1": "北京市", "note": "中央直属部队" } ], "津": [ { "admin1": "天津市", "note": "province abbreviation only" } ], "津A": [ { "admin1": "天津市", "note": "公交车; 警用; 新能源汽车" } ], "津B": [ { "admin1": "天津市", "note": "whole municipality" } ], "津C": [ { "admin1": "天津市", "note": "whole municipality" } ], "津D": [ { "admin1": "天津市", "note": "whole municipality" } ], "津F": [ { "admin1": "天津市", "note": "whole municipality" } ], "津G": [ { "admin1": "天津市", "note": "whole municipality" } ], "津H": [ { "admin1": "天津市", "note": "whole municipality" } ], "津J": [ { "admin1": "天津市", "note": "whole municipality" } ], "津K": [ { "admin1": "天津市", "note": "whole municipality" } ], "津L": [ { "admin1": "天津市", "note": "whole municipality" } ], "津M": [ { "admin1": "天津市", "note": "whole municipality" } ], "津N": [ { "admin1": "天津市", "note": "whole municipality" } ], "津Q": [ { "admin1": "天津市", "note": "whole municipality" } ], "津R": [ { "admin1": "天津市", "note": "whole municipality" } ], "津E": [ { "admin1": "天津市", "note": "出租车" } ], "冀": [ { "admin1": "河北省", "note": "province abbreviation only" } ], "冀A": [ { "admin1": "河北省", "admin2": "石家庄市" } ], "冀B": [ { "admin1": "河北省", "admin2": "唐山市" } ], "冀C": [ { "admin1": "河北省", "admin2": "秦皇岛市" } ], "冀D": [ { "admin1": "河北省", "admin2": "邯郸市" } ], "冀E": [ { "admin1": "河北省", "admin2": "邢台市" } ], "冀F": [ { "admin1": "河北省", "admin2": "保定市" } ], "冀G": [ { "admin1": "河北省", "admin2": "张家口市" } ], "冀H": [ { "admin1": "河北省", "admin2": "承德市" } ], "冀J": [ { "admin1": "河北省", "admin2": "沧州市" } ], "冀K": [ { "admin1": "河北省", "note": "邯郸地区(实际上此号牌从未启用)" } ], "冀L": [ { "admin1": "河北省", "note": "邢台地区(实际上此号牌从未启用)" } ], "冀M": [ { "admin1": "河北省", "note": "石家庄地区(实际上此号牌从未启用)" } ], "冀N": [ { "admin1": "河北省", "note": "保定地区" } ], "冀O": [ { "admin1": "河北省", "note": "省直机关; 外资用" } ], "冀P": [ { "admin1": "河北省", "note": "张家口地区(实际上此号牌从未启用)" } ], "冀Q": [ { "admin1": "河北省", "note": "承德地区(实际上此号牌从未启用)" } ], "冀R": [ { "admin1": "河北省", "admin2": "廊坊市" } ], "冀S": [ { "admin1": "河北省", "note": "沧州地区(实际上此号牌从未启用)" } ], "冀T": [ { "admin1": "河北省", "admin2": "衡水市" } ], "冀X": [ { "admin1": "河北省", "note": "雄安新区" } ], "晋": [ { "admin1": "山西省", "note": "province abbreviation only" } ], "晋A": [ { "admin1": "山西省", "admin2": "太原市" } ], "晋B": [ { "admin1": "山西省", "admin2": "大同市" } ], "晋C": [ { "admin1": "山西省", "admin2": "阳泉市" } ], "晋D": [ { "admin1": "山西省", "admin2": "长治市" } ], "晋E": [ { "admin1": "山西省", "admin2": "晋城市" } ], "晋F": [ { "admin1": "山西省", "admin2": "朔州市" } ], "晋G": [ { "admin1": "山西省", "note": "雁北地区(实际上此号牌从未启用)" } ], "晋H": [ { "admin1": "山西省", "admin2": "忻州市" } ], "晋J": [ { "admin1": "山西省", "admin2": "吕梁市" } ], "晋K": [ { "admin1": "山西省", "admin2": "晋中市" } ], "晋L": [ { "admin1": "山西省", "admin2": "临汾市" } ], "晋M": [ { "admin1": "山西省", "admin2": "运城市" } ], "晋N": [ { "admin1": "山西省", "note": "外资用" } ], "晋O": [ { "admin1": "山西省", "note": "警用" } ], "蒙": [ { "admin1": "内蒙古自治区", "note": "province abbreviation only" } ], "蒙A": [ { "admin1": "内蒙古自治区", "admin2": "呼和浩特市" } ], "蒙B": [ { "admin1": "内蒙古自治区", "admin2": "包头市" } ], "蒙C": [ { "admin1": "内蒙古自治区", "admin2": "乌海市" } ], "蒙D": [ { "admin1": "内蒙古自治区", "admin2": "赤峰市" } ], "蒙E": [ { "admin1": "内蒙古自治区", "admin2": "呼伦贝尔市" } ], "蒙F": [ { "admin1": "内蒙古自治区", "admin2": "兴安盟" } ], "蒙G": [ { "admin1": "内蒙古自治区", "admin2": "通辽市" } ], "蒙H": [ { "admin1": "内蒙古自治区", "admin2": "锡林郭勒盟" } ], "蒙J": [ { "admin1": "内蒙古自治区", "admin2": "乌兰察布市" } ], "蒙K": [ { "admin1": "内蒙古自治区", "admin2": "鄂尔多斯市" } ], "蒙L": [ { "admin1": "内蒙古自治区", "admin2": "巴彦淖尔市" } ], "蒙M": [ { "admin1": "内蒙古自治区", "admin2": "阿拉善盟" } ], "蒙O": [ { "admin1": "内蒙古自治区", "note": "警用" } ], "辽": [ { "admin1": "辽宁省", "note": "province abbreviation only" } ], "辽A": [ { "admin1": "辽宁省", "admin2": "沈阳市" } ], "辽B": [ { "admin1": "辽宁省", "admin2": "大连市" } ], "辽C": [ { "admin1": "辽宁省", "admin2": "鞍山市" } ], "辽D": [ { "admin1": "辽宁省", "admin2": "抚顺市" } ], "辽E": [ { "admin1": "辽宁省", "admin2": "本溪市" } ], "辽F": [ { "admin1": "辽宁省", "admin2": "丹东市" } ], "辽G": [ { "admin1": "辽宁省", "admin2": "锦州市" } ], "辽H": [ { "admin1": "辽宁省", "admin2": "营口市" } ], "辽J": [ { "admin1": "辽宁省", "admin2": "阜新市" } ], "辽K": [ { "admin1": "辽宁省", "admin2": "辽阳市" } ], "辽L": [ { "admin1": "辽宁省", "admin2": "盘锦市" } ], "辽M": [ { "admin1": "辽宁省", "admin2": "铁岭市" } ], "辽N": [ { "admin1": "辽宁省", "admin2": "朝阳市" } ], "辽O": [ { "admin1": "辽宁省", "note": "公安; 外资用" } ], "辽P": [ { "admin1": "辽宁省", "admin2": "葫芦岛市" } ], "辽V": [ { "admin1": "辽宁省", "note": "省直系统" } ], "吉": [ { "admin1": "吉林省", "note": "province abbreviation only" } ], "吉A": [ { "admin1": "吉林省", "admin2": "长春市" } ], "吉B": [ { "admin1": "吉林省", "admin2": "吉林市" } ], "吉C": [ { "admin1": "吉林省", "admin2": "四平市" } ], "吉D": [ { "admin1": "吉林省", "admin2": "辽源市" } ], "吉E": [ { "admin1": "吉林省", "admin2": "通化市" } ], "吉F": [ { "admin1": "吉林省", "admin2": "白山市" } ], "吉G": [ { "admin1": "吉林省", "admin2": "白城市" } ], "吉H": [ { "admin1": "吉林省", "admin2": "延边朝鲜族自治州" } ], "吉J": [ { "admin1": "吉林省", "admin2": "松原市" } ], "吉K": [ { "admin1": "吉林省", "note": "长白山保护开发区" } ], "吉O": [ { "admin1": "吉林省", "note": "公安; 外资用" } ], "黑": [ { "admin1": "黑龙江省", "note": "province abbreviation only" } ], "黑A": [ { "admin1": "黑龙江省", "admin2": "哈尔滨市" } ], "黑L": [ { "admin1": "黑龙江省", "admin2": "哈尔滨市" } ], "黑B": [ { "admin1": "黑龙江省", "admin2": "齐齐哈尔市" } ], "黑C": [ { "admin1": "黑龙江省", "admin2": "牡丹江市" } ], "黑D": [ { "admin1": "黑龙江省", "admin2": "佳木斯市" } ], "黑E": [ { "admin1": "黑龙江省", "admin2": "大庆市" } ], "黑F": [ { "admin1": "黑龙江省", "admin2": "伊春市" } ], "黑G": [ { "admin1": "黑龙江省", "admin2": "鸡西市" } ], "黑H": [ { "admin1": "黑龙江省", "admin2": "鹤岗市" } ], "黑J": [ { "admin1": "黑龙江省", "admin2": "双鸭山市" } ], "黑K": [ { "admin1": "黑龙江省", "admin2": "七台河市" } ], "黑M": [ { "admin1": "黑龙江省", "admin2": "绥化市" } ], "黑N": [ { "admin1": "黑龙江省", "admin2": "黑河市" } ], "黑O": [ { "admin1": "黑龙江省", "note": "公安; 外资用" } ], "黑P": [ { "admin1": "黑龙江省", "admin2": "大兴安岭地区" } ], "黑R": [ { "admin1": "黑龙江省", "note": "农垦系统" } ], "沪": [ { "admin1": "上海市", "note": "province abbreviation only" } ], "沪A": [ { "admin1": "上海市", "note": "市区汽车" } ], "沪B": [ { "admin1": "上海市", "note": "市区汽车" } ], "沪D": [ { "admin1": "上海市", "note": "市区汽车" } ], "沪E": [ { "admin1": "上海市", "note": "市区汽车" } ], "沪F": [ { "admin1": "上海市", "note": "市区汽车" } ], "沪G": [ { "admin1": "上海市", "note": "市区汽车" } ], "沪H": [ { "admin1": "上海市", "note": "市区汽车" } ], "沪J": [ { "admin1": "上海市", "note": "市区汽车" } ], "沪K": [ { "admin1": "上海市", "note": "市区汽车" } ], "沪L": [ { "admin1": "上海市", "note": "市区汽车" } ], "沪M": [ { "admin1": "上海市", "note": "市区汽车" } ], "沪N": [ { "admin1": "上海市", "note": "市区汽车" } ], "沪C": [ { "admin1": "上海市", "note": "郊区汽车" } ], "苏": [ { "admin1": "江苏省", "note": "province abbreviation only" } ], "苏A": [ { "admin1": "江苏省", "admin2": "南京市" } ], "苏B": [ { "admin1": "江苏省", "admin2": "无锡市" } ], "苏C": [ { "admin1": "江苏省", "admin2": "徐州市" } ], "苏D": [ { "admin1": "江苏省", "admin2": "常州市" } ], "苏E": [ { "admin1": "江苏省", "admin2": "苏州市" } ], "苏U": [ { "admin1": "江苏省", "admin2": "苏州市" } ], "苏F": [ { "admin1": "江苏省", "admin2": "南通市" } ], "苏G": [ { "admin1": "江苏省", "admin2": "连云港市" } ], "苏H": [ { "admin1": "江苏省", "admin2": "淮安市" } ], "苏J": [ { "admin1": "江苏省", "admin2": "盐城市" } ], "苏K": [ { "admin1": "江苏省", "admin2": "扬州市" } ], "苏L": [ { "admin1": "江苏省", "admin2": "镇江市" } ], "苏M": [ { "admin1": "江苏省", "admin2": "泰州市" } ], "苏N": [ { "admin1": "江苏省", "admin2": "宿迁市" } ], "苏O": [ { "admin1": "江苏省", "note": "省直机关; 外资用" } ], "浙": [ { "admin1": "浙江省", "note": "province abbreviation only" } ], "浙A": [ { "admin1": "浙江省", "admin2": "杭州市" } ], "浙M": [ { "admin1": "浙江省", "admin2": "杭州市" } ], "浙B": [ { "admin1": "浙江省", "admin2": "宁波市" } ], "浙C": [ { "admin1": "浙江省", "admin2": "温州市" } ], "浙D": [ { "admin1": "浙江省", "admin2": "绍兴市" } ], "浙E": [ { "admin1": "浙江省", "admin2": "湖州市" } ], "浙F": [ { "admin1": "浙江省", "admin2": "嘉兴市" } ], "浙G": [ { "admin1": "浙江省", "admin2": "金华市" } ], "浙H": [ { "admin1": "浙江省", "admin2": "衢州市" } ], "浙J": [ { "admin1": "浙江省", "admin2": "台州市" } ], "浙K": [ { "admin1": "浙江省", "admin2": "丽水市" } ], "浙L": [ { "admin1": "浙江省", "admin2": "舟山市" } ], "浙O": [ { "admin1": "浙江省", "note": "省直机关; 外资用" } ], "皖": [ { "admin1": "安徽省", "note": "province abbreviation only" } ], "皖A": [ { "admin1": "安徽省", "admin2": "合肥市" } ], "皖B": [ { "admin1": "安徽省", "admin2": "芜湖市" } ], "皖C": [ { "admin1": "安徽省", "admin2": "蚌埠市" } ], "皖D": [ { "admin1": "安徽省", "admin2": "淮南市" } ], "皖E": [ { "admin1": "安徽省", "admin2": "马鞍山市" } ], "皖F": [ { "admin1": "安徽省", "admin2": "淮北市" } ], "皖G": [ { "admin1": "安徽省", "admin2": "铜陵市" } ], "皖H": [ { "admin1": "安徽省", "admin2": "安庆市" } ], "皖J": [ { "admin1": "安徽省", "admin2": "黄山市" } ], "皖K": [ { "admin1": "安徽省", "admin2": "阜阳市" } ], "皖L": [ { "admin1": "安徽省", "admin2": "宿州市" } ], "皖M": [ { "admin1": "安徽省", "admin2": "滁州市" } ], "皖N": [ { "admin1": "安徽省", "admin2": "六安市" } ], "皖O": [ { "admin1": "安徽省", "note": "省直机关; 外资用" } ], "皖P": [ { "admin1": "安徽省", "admin2": "宣城市" } ], "皖Q": [ { "admin1": "安徽省", "admin2": "合肥市", "district": "巢湖市" } ], "皖R": [ { "admin1": "安徽省", "admin2": "池州市" } ], "皖S": [ { "admin1": "安徽省", "admin2": "亳州市" } ], "闽": [ { "admin1": "福建省", "note": "province abbreviation only" } ], "闽A": [ { "admin1": "福建省", "admin2": "福州市" } ], "闽B": [ { "admin1": "福建省", "admin2": "莆田市" } ], "闽C": [ { "admin1": "福建省", "admin2": "泉州市" } ], "闽D": [ { "admin1": "福建省", "admin2": "厦门市" } ], "闽E": [ { "admin1": "福建省", "admin2": "漳州市" } ], "闽F": [ { "admin1": "福建省", "admin2": "龙岩市" } ], "闽G": [ { "admin1": "福建省", "admin2": "三明市" } ], "闽H": [ { "admin1": "福建省", "admin2": "南平市" } ], "闽J": [ { "admin1": "福建省", "admin2": "宁德市" } ], "闽K": [ { "admin1": "福建省", "note": "平潭综合实验区; 省直系统" } ], "闽O": [ { "admin1": "福建省", "note": "警用" } ], "赣": [ { "admin1": "江西省", "note": "province abbreviation only" } ], "赣A": [ { "admin1": "江西省", "admin2": "南昌市" } ], "赣M": [ { "admin1": "江西省", "admin2": "南昌市" } ], "赣B": [ { "admin1": "江西省", "admin2": "赣州市" } ], "赣C": [ { "admin1": "江西省", "admin2": "宜春市" } ], "赣D": [ { "admin1": "江西省", "admin2": "吉安市" } ], "赣E": [ { "admin1": "江西省", "admin2": "上饶市" } ], "赣F": [ { "admin1": "江西省", "admin2": "抚州市" } ], "赣G": [ { "admin1": "江西省", "admin2": "九江市" } ], "赣H": [ { "admin1": "江西省", "admin2": "景德镇市" } ], "赣J": [ { "admin1": "江西省", "admin2": "萍乡市" } ], "赣K": [ { "admin1": "江西省", "admin2": "新余市" } ], "赣L": [ { "admin1": "江西省", "admin2": "鹰潭市" } ], "赣O": [ { "admin1": "江西省", "note": "高速交警" } ], "鲁": [ { "admin1": "山东省", "note": "province abbreviation only" } ], "鲁A": [ { "admin1": "山东省", "admin2": "济南市" } ], "鲁S": [ { "admin1": "山东省", "admin2": "济南市" } ], "鲁B": [ { "admin1": "山东省", "admin2": "青岛市" } ], "鲁U": [ { "admin1": "山东省", "admin2": "青岛市" } ], "鲁C": [ { "admin1": "山东省", "admin2": "淄博市" } ], "鲁D": [ { "admin1": "山东省", "admin2": "枣庄市" } ], "鲁E": [ { "admin1": "山东省", "admin2": "东营市" } ], "鲁F": [ { "admin1": "山东省", "admin2": "烟台市" } ], "鲁Y": [ { "admin1": "山东省", "admin2": "烟台市" } ], "鲁G": [ { "admin1": "山东省", "admin2": "潍坊市" } ], "鲁V": [ { "admin1": "山东省", "admin2": "潍坊市" } ], "鲁H": [ { "admin1": "山东省", "admin2": "济宁市" } ], "鲁J": [ { "admin1": "山东省", "admin2": "泰安市" } ], "鲁K": [ { "admin1": "山东省", "admin2": "威海市" } ], "鲁L": [ { "admin1": "山东省", "admin2": "日照市" } ], "鲁M": [ { "admin1": "山东省", "admin2": "滨州市" } ], "鲁N": [ { "admin1": "山东省", "admin2": "德州市" } ], "鲁O": [ { "admin1": "山东省", "note": "警用" } ], "鲁P": [ { "admin1": "山东省", "admin2": "聊城市" } ], "鲁Q": [ { "admin1": "山东省", "admin2": "临沂市" } ], "鲁W": [ { "admin1": "山东省", "admin2": "临沂市" } ], "鲁R": [ { "admin1": "山东省", "admin2": "菏泽市" } ], "豫": [ { "admin1": "河南省", "note": "province abbreviation only" } ], "豫A": [ { "admin1": "河南省", "admin2": "郑州市" } ], "豫V": [ { "admin1": "河南省", "admin2": "郑州市" } ], "豫B": [ { "admin1": "河南省", "admin2": "开封市" } ], "豫C": [ { "admin1": "河南省", "admin2": "洛阳市" } ], "豫D": [ { "admin1": "河南省", "admin2": "平顶山市" } ], "豫E": [ { "admin1": "河南省", "admin2": "安阳市" } ], "豫F": [ { "admin1": "河南省", "admin2": "鹤壁市" } ], "豫G": [ { "admin1": "河南省", "admin2": "新乡市" } ], "豫H": [ { "admin1": "河南省", "admin2": "焦作市" } ], "豫J": [ { "admin1": "河南省", "admin2": "濮阳市" } ], "豫K": [ { "admin1": "河南省", "admin2": "许昌市" } ], "豫L": [ { "admin1": "河南省", "admin2": "漯河市" } ], "豫M": [ { "admin1": "河南省", "admin2": "三门峡市" } ], "豫N": [ { "admin1": "河南省", "admin2": "商丘市" } ], "豫O": [ { "admin1": "河南省", "note": "省直机关; 外资用" } ], "豫P": [ { "admin1": "河南省", "admin2": "周口市" } ], "豫Q": [ { "admin1": "河南省", "admin2": "驻马店市" } ], "豫R": [ { "admin1": "河南省", "admin2": "南阳市" } ], "豫S": [ { "admin1": "河南省", "admin2": "信阳市" } ], "豫U": [ { "admin1": "河南省", "district": "济源市" } ], "鄂": [ { "admin1": "湖北省", "note": "province abbreviation only" } ], "鄂A": [ { "admin1": "湖北省", "admin2": "武汉市" } ], "鄂W": [ { "admin1": "湖北省", "admin2": "武汉市" } ], "鄂B": [ { "admin1": "湖北省", "admin2": "黄石市" } ], "鄂C": [ { "admin1": "湖北省", "admin2": "十堰市" } ], "鄂D": [ { "admin1": "湖北省", "admin2": "荆州市" } ], "鄂E": [ { "admin1": "湖北省", "admin2": "宜昌市" } ], "鄂F": [ { "admin1": "湖北省", "admin2": "襄阳市" } ], "鄂G": [ { "admin1": "湖北省", "admin2": "鄂州市" } ], "鄂H": [ { "admin1": "湖北省", "admin2": "荆门市" } ], "鄂J": [ { "admin1": "湖北省", "admin2": "黄冈市" } ], "鄂K": [ { "admin1": "湖北省", "admin2": "孝感市" } ], "鄂L": [ { "admin1": "湖北省", "admin2": "咸宁市" } ], "鄂M": [ { "admin1": "湖北省", "district": "仙桃市" } ], "鄂N": [ { "admin1": "湖北省", "district": "潜江市" } ], "鄂O": [ { "admin1": "湖北省", "note": "警用" } ], "鄂P": [ { "admin1": "湖北省", "district": "神农架林区" } ], "鄂Q": [ { "admin1": "湖北省", "admin2": "恩施土家族苗族自治州" } ], "鄂R": [ { "admin1": "湖北省", "district": "天门市" } ], "鄂S": [ { "admin1": "湖北省", "admin2": "随州市" } ], "湘": [ { "admin1": "湖南省", "note": "province abbreviation only" } ], "湘A": [ { "admin1": "湖南省", "admin2": "长沙市" } ], "湘B": [ { "admin1": "湖南省", "admin2": "株洲市" } ], "湘C": [ { "admin1": "湖南省", "admin2": "湘潭市" } ], "湘D": [ { "admin1": "湖南省", "admin2": "衡阳市" } ], "湘E": [ { "admin1": "湖南省", "admin2": "邵阳市" } ], "湘F": [ { "admin1": "湖南省", "admin2": "岳阳市" } ], "湘G": [ { "admin1": "湖南省", "admin2": "张家界市" } ], "湘H": [ { "admin1": "湖南省", "admin2": "益阳市" } ], "湘J": [ { "admin1": "湖南省", "admin2": "常德市" } ], "湘K": [ { "admin1": "湖南省", "admin2": "娄底市" } ], "湘L": [ { "admin1": "湖南省", "admin2": "郴州市" } ], "湘M": [ { "admin1": "湖南省", "admin2": "永州市" } ], "湘N": [ { "admin1": "湖南省", "admin2": "怀化市" } ], "湘O": [ { "admin1": "湖南省", "note": "公务用车" } ], "湘S": [ { "admin1": "湖南省", "note": "省直" } ], "湘U": [ { "admin1": "湖南省", "admin2": "湘西土家族苗族自治州" } ], "粤": [ { "admin1": "广东省", "note": "province abbreviation only" } ], "粤A": [ { "admin1": "广东省", "admin2": "广州市" } ], "粤B": [ { "admin1": "广东省", "admin2": "深圳市" } ], "粤C": [ { "admin1": "广东省", "admin2": "珠海市" } ], "粤D": [ { "admin1": "广东省", "admin2": "汕头市" } ], "粤E": [ { "admin1": "广东省", "admin2": "佛山市" } ], "粤F": [ { "admin1": "广东省", "admin2": "韶关市" } ], "粤G": [ { "admin1": "广东省", "admin2": "湛江市" } ], "粤H": [ { "admin1": "广东省", "admin2": "肇庆市" } ], "粤J": [ { "admin1": "广东省", "admin2": "江门市" } ], "粤K": [ { "admin1": "广东省", "admin2": "茂名市" } ], "粤L": [ { "admin1": "广东省", "admin2": "惠州市" } ], "粤M": [ { "admin1": "广东省", "admin2": "梅州市" } ], "粤N": [ { "admin1": "广东省", "admin2": "汕尾市" } ], "粤O": [ { "admin1": "广东省", "note": "省直机关" } ], "粤P": [ { "admin1": "广东省", "admin2": "河源市" } ], "粤Q": [ { "admin1": "广东省", "admin2": "阳江市" } ], "粤R": [ { "admin1": "广东省", "admin2": "清远市" } ], "粤S": [ { "admin1": "广东省", "admin2": "东莞市" } ], "粤T": [ { "admin1": "广东省", "admin2": "中山市" } ], "粤U": [ { "admin1": "广东省", "admin2": "潮州市" } ], "粤V": [ { "admin1": "广东省", "admin2": "揭阳市" } ], "粤W": [ { "admin1": "广东省", "admin2": "云浮市" } ], "粤X": [ { "admin1": "广东省", "admin2": "佛山市", "district": "顺德区" } ], "粤Y": [ { "admin1": "广东省", "admin2": "佛山市", "district": "南海区" } ], "粤Z": [ { "admin1": "广东省", "note": "香港特别行政区; 澳门特别行政区" } ], "桂": [ { "admin1": "广西壮族自治区", "note": "province abbreviation only" } ], "桂A": [ { "admin1": "广西壮族自治区", "admin2": "南宁市" } ], "桂B": [ { "admin1": "广西壮族自治区", "admin2": "柳州市" } ], "桂C": [ { "admin1": "广西壮族自治区", "admin2": "桂林市" } ], "桂D": [ { "admin1": "广西壮族自治区", "admin2": "梧州市" } ], "桂E": [ { "admin1": "广西壮族自治区", "admin2": "北海市" } ], "桂F": [ { "admin1": "广西壮族自治区", "admin2": "崇左市" } ], "桂G": [ { "admin1": "广西壮族自治区", "admin2": "来宾市" } ], "桂H": [ { "admin1": "广西壮族自治区", "note": "桂林地区" } ], "桂J": [ { "admin1": "广西壮族自治区", "admin2": "贺州市" } ], "桂K": [ { "admin1": "广西壮族自治区", "admin2": "玉林市" } ], "桂L": [ { "admin1": "广西壮族自治区", "admin2": "百色市" } ], "桂M": [ { "admin1": "广西壮族自治区", "admin2": "河池市" } ], "桂N": [ { "admin1": "广西壮族自治区", "admin2": "钦州市" } ], "桂O": [ { "admin1": "广西壮族自治区", "note": "警用" } ], "桂P": [ { "admin1": "广西壮族自治区", "admin2": "防城港市" } ], "桂R": [ { "admin1": "广西壮族自治区", "admin2": "贵港市" } ], "琼": [ { "admin1": "海南省", "note": "province abbreviation only" } ], "琼A": [ { "admin1": "海南省", "admin2": "海口市" } ], "琼B": [ { "admin1": "海南省", "admin2": "三亚市" } ], "琼C": [ { "admin1": "海南省", "district": "琼海市" }, { "admin1": "海南省", "district": "定安县" }, { "admin1": "海南省", "district": "屯昌县" }, { "admin1": "海南省", "district": "澄迈县" }, { "admin1": "海南省", "district": "临高县" }, { "admin1": "海南省", "district": "文昌市" }, { "admin1": "海南省", "note": "万宁市 CXS 三沙市" } ], "琼D": [ { "admin1": "海南省", "district": "五指山市" }, { "admin1": "海南省", "district": "陵水黎族自治县" }, { "admin1": "海南省", "district": "保亭黎族苗族自治县" }, { "admin1": "海南省", "district": "琼中黎族苗族自治县" }, { "admin1": "海南省", "district": "白沙黎族自治县" }, { "admin1": "海南省", "district": "昌江黎族自治县" }, { "admin1": "海南省", "district": "东方市" }, { "admin1": "海南省", "district": "乐东黎族自治县" } ], "琼E": [ { "admin1": "海南省", "admin2": "儋州市", "district": "洋浦经济开发区" } ], "琼F": [ { "admin1": "海南省", "admin2": "儋州市" } ], "琼O": [ { "admin1": "海南省", "note": "警用" } ], "渝": [ { "admin1": "重庆市", "note": "province abbreviation only" } ], "渝A": [ { "admin1": "重庆市", "note": "whole municipality; district split from common knowledge: main urban districts", "unverified": true } ], "渝B": [ { "admin1": "重庆市", "note": "whole municipality; district split from common knowledge: main urban districts", "unverified": true } ], "渝C": [ { "admin1": "重庆市", "note": "whole municipality; district split from common knowledge: 永川、江津、合川、璧山、铜梁、大足、荣昌、潼南 (former 永川 prefecture)", "unverified": true } ], "渝D": [ { "admin1": "重庆市", "note": "whole municipality; district split from common knowledge: main urban districts (added later)", "unverified": true } ], "渝F": [ { "admin1": "重庆市", "note": "whole municipality; district split from common knowledge: 万州、开州、梁平、忠县、云阳、奉节、巫山、巫溪、城口 (former 万县 prefecture)", "unverified": true } ], "渝G": [ { "admin1": "重庆市", "note": "whole municipality; district split from common knowledge: 涪陵、南川、垫江、丰都、武隆 (former 涪陵 prefecture)", "unverified": true } ], "渝H": [ { "admin1": "重庆市", "note": "whole municipality; district split from common knowledge: 黔江、石柱、秀山、酉阳、彭水 (former 黔江 prefecture)", "unverified": true } ], "渝N": [ { "admin1": "重庆市", "note": "政企专用" } ], "渝O": [ { "admin1": "重庆市", "note": "警用" } ], "川": [ { "admin1": "四川省", "note": "province abbreviation only" } ], "川A": [ { "admin1": "四川省", "admin2": "成都市" } ], "川G": [ { "admin1": "四川省", "admin2": "成都市" } ], "川B": [ { "admin1": "四川省", "admin2": "绵阳市" } ], "川C": [ { "admin1": "四川省", "admin2": "自贡市" } ], "川D": [ { "admin1": "四川省", "admin2": "攀枝花市" } ], "川E": [ { "admin1": "四川省", "admin2": "泸州市" } ], "川F": [ { "admin1": "四川省", "admin2": "德阳市" } ], "川H": [ { "admin1": "四川省", "admin2": "广元市" } ], "川J": [ { "admin1": "四川省", "admin2": "遂宁市" } ], "川K": [ { "admin1": "四川省", "admin2": "内江市" } ], "川L": [ { "admin1": "四川省", "admin2": "乐山市" } ], "川M": [ { "admin1": "四川省", "admin2": "资阳市" } ], "川N": [ { "admin1": "四川省", "note": "涪陵市" } ], "川O": [ { "admin1": "四川省", "note": "四川省公安厅车辆管理所" } ], "川P": [ { "admin1": "四川省", "note": "黔江地区" } ], "川Q": [ { "admin1": "四川省", "admin2": "宜宾市" } ], "川R": [ { "admin1": "四川省", "admin2": "南充市" } ], "川S": [ { "admin1": "四川省", "admin2": "达州市" } ], "川T": [ { "admin1": "四川省", "admin2": "雅安市" } ], "川U": [ { "admin1": "四川省", "admin2": "阿坝藏族羌族自治州" } ], "川V": [ { "admin1": "四川省", "admin2": "甘孜藏族自治州" } ], "川W": [ { "admin1": "四川省", "admin2": "凉山彝族自治州" } ], "川X": [ { "admin1": "四川省", "admin2": "广安市" } ], "川Y": [ { "admin1": "四川省", "admin2": "巴中市" } ], "川Z": [ { "admin1": "四川省", "admin2": "眉山市" } ], "贵": [ { "admin1": "贵州省", "note": "province abbreviation only" } ], "贵A": [ { "admin1": "贵州省", "admin2": "贵阳市" } ], "贵B": [ { "admin1": "贵州省", "admin2": "六盘水市" } ], "贵C": [ { "admin1": "贵州省", "admin2": "遵义市" } ], "贵D": [ { "admin1": "贵州省", "admin2": "铜仁市" } ], "贵E": [ { "admin1": "贵州省", "admin2": "黔西南布依族苗族自治州" } ], "贵F": [ { "admin1": "贵州省", "admin2": "毕节市" } ], "贵G": [ { "admin1": "贵州省", "admin2": "安顺市" } ], "贵H": [ { "admin1": "贵州省", "admin2": "黔东南苗族侗族自治州" } ], "贵J": [ { "admin1": "贵州省", "admin2": "黔南布依族苗族自治州" } ], "贵O": [ { "admin1": "贵州省", "note": "省直机关; 外资用" } ], "云": [ { "admin1": "云南省", "note": "province abbreviation only" } ], "云A": [ { "admin1": "云南省", "admin2": "昆明市" } ], "云B": [ { "admin1": "云南省", "admin2": "昆明市", "district": "东川区" } ], "云C": [ { "admin1": "云南省", "admin2": "昭通市" } ], "云D": [ { "admin1": "云南省", "admin2": "曲靖市" } ], "云E": [ { "admin1": "云南省", "admin2": "楚雄彝族自治州" } ], "云F": [ { "admin1": "云南省", "admin2": "玉溪市" } ], "云G": [ { "admin1": "云南省", "admin2": "红河哈尼族彝族自治州" } ], "云H": [ { "admin1": "云南省", "admin2": "文山壮族苗族自治州" } ], "云J": [ { "admin1": "云南省", "admin2": "普洱市" } ], "云K": [ { "admin1": "云南省", "admin2": "西双版纳傣族自治州" } ], "云L": [ { "admin1": "云南省", "admin2": "大理白族自治州" } ], "云M": [ { "admin1": "云南省", "admin2": "保山市" } ], "云N": [ { "admin1": "云南省", "admin2": "德宏傣族景颇族自治州" } ], "云O": [ { "admin1": "云南省", "note": "公安" } ], "云P": [ { "admin1": "云南省", "admin2": "丽江市" } ], "云Q": [ { "admin1": "云南省", "admin2": "怒江傈僳族自治州" } ], "云R": [ { "admin1": "云南省", "admin2": "迪庆藏族自治州" } ], "云S": [ { "admin1": "云南省", "admin2": "临沧市" } ], "藏": [ { "admin1": "西藏自治区", "note": "province abbreviation only" } ], "藏A": [ { "admin1": "西藏自治区", "admin2": "拉萨市" } ], "藏B": [ { "admin1": "西藏自治区", "admin2": "昌都市" } ], "藏C": [ { "admin1": "西藏自治区", "admin2": "山南市" } ], "藏D": [ { "admin1": "西藏自治区", "admin2": "日喀则市" } ], "藏E": [ { "admin1": "西藏自治区", "admin2": "那曲市" } ], "藏F": [ { "admin1": "西藏自治区", "admin2": "阿里地区" } ], "藏G": [ { "admin1": "西藏自治区", "admin2": "林芝市" } ], "藏H": [ { "admin1": "西藏自治区", "note": "驻四川省天全县车辆管理所" } ], "藏J": [ { "admin1": "西藏自治区", "note": "驻青海省格尔木市车辆管理所" } ], "藏O": [ { "admin1": "西藏自治区", "note": "警用" } ], "陕": [ { "admin1": "陕西省", "note": "province abbreviation only" } ], "陕A": [ { "admin1": "陕西省", "admin2": "西安市" } ], "陕U": [ { "admin1": "陕西省", "admin2": "西安市" } ], "陕B": [ { "admin1": "陕西省", "admin2": "铜川市" } ], "陕C": [ { "admin1": "陕西省", "admin2": "宝鸡市" } ], "陕D": [ { "admin1": "陕西省", "admin2": "咸阳市" } ], "陕E": [ { "admin1": "陕西省", "admin2": "渭南市" } ], "陕F": [ { "admin1": "陕西省", "admin2": "汉中市" } ], "陕G": [ { "admin1": "陕西省", "admin2": "安康市" } ], "陕H": [ { "admin1": "陕西省", "admin2": "商洛市" } ], "陕J": [ { "admin1": "陕西省", "admin2": "延安市" } ], "陕K": [ { "admin1": "陕西省", "admin2": "榆林市" } ], "陕O": [ { "admin1": "陕西省", "note": "省直机关; 外资用" } ], "陕V": [ { "admin1": "陕西省", "note": "杨凌农业高新技术产业示范区" } ], "甘": [ { "admin1": "甘肃省", "note": "province abbreviation only" } ], "甘A": [ { "admin1": "甘肃省", "admin2": "兰州市" } ], "甘B": [ { "admin1": "甘肃省", "admin2": "嘉峪关市" } ], "甘C": [ { "admin1": "甘肃省", "admin2": "金昌市" } ], "甘D": [ { "admin1": "甘肃省", "admin2": "白银市" } ], "甘E": [ { "admin1": "甘肃省", "admin2": "天水市" } ], "甘F": [ { "admin1": "甘肃省", "admin2": "酒泉市" } ], "甘G": [ { "admin1": "甘肃省", "admin2": "张掖市" } ], "甘H": [ { "admin1": "甘肃省", "admin2": "武威市" } ], "甘J": [ { "admin1": "甘肃省", "admin2": "定西市" } ], "甘K": [ { "admin1": "甘肃省", "admin2": "陇南市" } ], "甘L": [ { "admin1": "甘肃省", "admin2": "平凉市" } ], "甘M": [ { "admin1": "甘肃省", "admin2": "庆阳市" } ], "甘N": [ { "admin1": "甘肃省", "admin2": "临夏回族自治州" } ], "甘O": [ { "admin1": "甘肃省", "note": "警用" } ], "甘P": [ { "admin1": "甘肃省", "admin2": "甘南藏族自治州" } ], "青": [ { "admin1": "青海省", "note": "province abbreviation only" } ], "青A": [ { "admin1": "青海省", "admin2": "西宁市" } ], "青O": [ { "admin1": "青海省", "admin2": "西宁市" } ], "青B": [ { "admin1": "青海省", "admin2": "海东市" } ], "青C": [ { "admin1": "青海省", "admin2": "海北藏族自治州" } ], "青D": [ { "admin1": "青海省", "admin2": "黄南藏族自治州" } ], "青E": [ { "admin1": "青海省", "admin2": "海南藏族自治州" } ], "青F": [ { "admin1": "青海省", "admin2": "果洛藏族自治州" } ], "青G": [ { "admin1": "青海省", "admin2": "玉树藏族自治州" } ], "青H": [ { "admin1": "青海省", "admin2": "海西蒙古族藏族自治州" } ], "宁": [ { "admin1": "宁夏回族自治区", "note": "province abbreviation only" } ], "宁A": [ { "admin1": "宁夏回族自治区", "admin2": "银川市" } ], "宁B": [ { "admin1": "宁夏回族自治区", "admin2": "石嘴山市" } ], "宁C": [ { "admin1": "宁夏回族自治区", "admin2": "吴忠市" } ], "宁D": [ { "admin1": "宁夏回族自治区", "admin2": "固原市" } ], "宁E": [ { "admin1": "宁夏回族自治区", "admin2": "中卫市" } ], "宁O": [ { "admin1": "宁夏回族自治区", "note": "警用" } ], "新": [ { "admin1": "新疆维吾尔自治区", "note": "province abbreviation only" } ], "新A": [ { "admin1": "新疆维吾尔自治区", "admin2": "乌鲁木齐市" } ], "新B": [ { "admin1": "新疆维吾尔自治区", "admin2": "昌吉回族自治州" }, { "admin1": "新疆维吾尔自治区", "district": "五家渠市" } ], "新C": [ { "admin1": "新疆维吾尔自治区", "district": "石河子市" } ], "新D": [ { "admin1": "新疆维吾尔自治区", "admin2": "伊犁哈萨克自治州", "district": "奎屯市" }, { "admin1": "新疆维吾尔自治区", "district": "胡杨河市" } ], "新E": [ { "admin1": "新疆维吾尔自治区", "admin2": "博尔塔拉蒙古自治州" }, { "admin1": "新疆维吾尔自治区", "district": "双河市" } ], "新F": [ { "admin1": "新疆维吾尔自治区", "note": "伊犁哈萨克自治州(不含塔城地区; 阿勒泰地区和奎屯市)" }, { "admin1": "新疆维吾尔自治区", "district": "可克达拉市" } ], "新G": [ { "admin1": "新疆维吾尔自治区", "admin2": "塔城地区" }, { "admin1": "新疆维吾尔自治区", "district": "白杨市" } ], "新H": [ { "admin1": "新疆维吾尔自治区", "admin2": "阿勒泰地区" }, { "admin1": "新疆维吾尔自治区", "district": "北屯市" } ], "新J": [ { "admin1": "新疆维吾尔自治区", "admin2": "克拉玛依市" } ], "新K": [ { "admin1": "新疆维吾尔自治区", "admin2": "吐鲁番市" } ], "新L": [ { "admin1": "新疆维吾尔自治区", "admin2": "哈密市" }, { "admin1": "新疆维吾尔自治区", "district": "新星市" } ], "新M": [ { "admin1": "新疆维吾尔自治区", "admin2": "巴音郭楞蒙古自治州" }, { "admin1": "新疆维吾尔自治区", "district": "铁门关市" } ], "新N": [ { "admin1": "新疆维吾尔自治区", "admin2": "阿克苏地区" }, { "admin1": "新疆维吾尔自治区", "district": "阿拉尔市" } ], "新O": [ { "admin1": "新疆维吾尔自治区", "note": "警用" } ], "新P": [ { "admin1": "新疆维吾尔自治区", "admin2": "克孜勒苏柯尔克孜自治州" } ], "新Q": [ { "admin1": "新疆维吾尔自治区", "admin2": "喀什地区", "note": "不含叶城县" }, { "admin1": "新疆维吾尔自治区", "district": "图木舒克市" }, { "admin1": "新疆维吾尔自治区", "note": "草湖市" } ], "新R": [ { "admin1": "新疆维吾尔自治区", "admin2": "和田地区" }, { "admin1": "新疆维吾尔自治区", "admin2": "喀什地区", "district": "叶城县" } ], "新S": [ { "admin1": "新疆维吾尔自治区", "district": "昆玉市" } ] } }
-
-
clues.md 18.8 KB
# Mainland China clues Entries use the shared format (`references/clues/README.md`); lookups: `clues.py lookup plate|area-code|admin --country cn`. Ordered by the layers in `references/observe.md`: platform metadata → text and plates → vehicles and traffic → infrastructure → climate and phenology → terrain and water → urban form. ## Platform and metadata (all of it is a hypothesis to verify) ### IP location label on comments/posts - Look for: the province shown next to comments and posts on Douyin, Xiaohongshu, and Weibo (a country for users abroad), together with the posting date - Points to: which province the poster was in at the moment of posting, not where the photo is - Strength: weak; raise to medium when the stated story is "shot today, posted today" and the watermark time matches the posting time - Counterexamples: posting while traveling, old photos, going online through a proxy (v006: the photo was in a province neighboring the IP province); the puzzle setter's "not in X" is itself a hypothesis to verify. When it conflicts with the image, keep "matching areas in this province + neighboring provinces"; don't exclude a whole province - Sources: v004 v006 v010 v011 ### Photo timestamp watermark / EXIF time - Look for: a date-time watermark in a corner of the frame; DateTimeOriginal as read by `exif.py` - Points to: month and time of capture → phenology judgments, sun calculations - Strength: medium (the watermark can be wrong because of camera settings; EXIF can be edited) - Counterexamples: rephotographed images, metadata stripped after reposting; camera time zone not set - Sources: v006 v010-3 ## Text and plates ### Shop signs, street signs, organization names - Look for: place names on shop signs, company plaques, and street-name signs; search the original text - Points to: province / city / street - Strength: street-name signs and organization names strong; place names in shop signs medium (chain stores and out-of-town companies mislead) - Counterexamples: chain brands; shops named after somewhere else ("重庆火锅" Chongqing hotpot, "沙县小吃" Shaxian snacks) - Sources: general; case001 ### Issuing authority at the bottom of signs - Look for: small print in the corner of traffic signs, prohibition signs, public notices, and construction signs: 〇〇市公安局交警支队 (〇〇 city public security bureau, traffic police detachment), 〇〇区城管 (〇〇 district urban management), 〇〇街道办 (〇〇 subdistrict office) - Points to: city, district (county); often names the admin division directly - Strength: medium (if legible, it reaches the district; the name of an agency with cross-district jurisdiction is not the district you are in) - Counterexamples: blurry small print is easy to misread; it needs a second instance to confirm - Sources: v003 (the same technique used abroad; applies equally to signs in China) ### Landline area codes - Look for: landline numbers on signs, ads, and construction notices (mobile numbers don't map to a region) - Points to: roughly blocked by first digit: 010 Beijing, 02X municipalities and megacities (020 Guangzhou, 021 Shanghai, 022 Tianjin, 023 Chongqing, 024 Shenyang, 025 Nanjing, 027 Wuhan, 028 Chengdu, 029 Xi'an); 03 North China and the Central Plains (Hebei, Shanxi, Henan); 04 Northeast and Inner Mongolia; 05 East China (Jiangsu, Shandong, Anhui, Zhejiang, Fujian); 07 Central and South China (Hubei, Hunan, Guangdong, Guangxi, Jiangxi); 08 Southwest (Sichuan, Guizhou, Yunnan, Tibet); 09 Northwest (Shaanxi, Gansu, Ningxia, Qinghai, Xinjiang). Look up the full area code in the table to get the prefecture-level city - Strength: medium (a full area code reaches the city) - Counterexamples: first judge how many digits the area code has from the digit grouping; 400/800 numbers and mobile numbers don't map to a region; a company headquarters number may be in another city; a few area-code blocks have exceptions (e.g., codes starting with 06 belong to parts of Shandong, Guangdong, and Yunnan); the lookup table is authoritative - Sources: v003 (method); the block pattern is general knowledge, check the table before citing it ### License plates - Look for: the first Chinese character is the provincial abbreviation, the second-position letter is the prefecture-level city code (A is usually the provincial capital; exceptions: Qingdao 鲁B, Shenzhen 粤B, Dalian 辽B, Xiamen 闽D, etc.; check the table); background color: blue for small vehicles, yellow for large vehicles, gradient green for new-energy small vehicles, yellow-green for new-energy large vehicles, black for foreign-related vehicles and Hong Kong/Macau cross-border vehicles (粤Z) - Points to: province / prefecture-level city - Strength: strong when several vehicles on the street agree; a single vehicle, or vehicles in parking lots, scenic areas, at hotel entrances, or in expressway service areas, count only as weak - Counterexamples: out-of-town vehicles are common in big cities and tourist areas; freight corridors carry plates from everywhere; illegible out-of-town plates in hotel or scenic-area parking lots can't be used to place the location - Sources: case001 v011 ## Vehicles and traffic ### Taxi colors - Look for: main body color (green, yellow, blue-and-white, red, etc.) and color scheme - Points to: a city-level candidate list (e.g., green-family taxis are common in several Sichuan cities; look up the exact list live, it changes over the years) - Strength: weak (single source); medium when combined with bus livery and terrain - Counterexamples: one city often runs several colors at once, and they change generations often; Chongqing, Xi'an, Changzhi, and other places also have green-family taxis; ride-hailing cars don't count - Sources: v006 ### Bus roof and body livery (clear in top-down shots) - Look for: bus roof color, main body color, waistline stripe, rear-end pattern (zoomed top-down shots often leave only color blocks, which is still enough) - Points to: a fingerprint to compare city by city (first narrow down to a few cities with taxis etc., then separate them by bus livery); **inside a municipality or a large prefecture-level city it can separate districts (counties)**: the urban core and outlying districts/counties have different bus companies, with very different liveries - How to check: first confirm the common livery in the urban core; if it doesn't match, search images for "<城市> <颜色描述> 公交" (query in Chinese: <city> <color description> bus), and take the district/county name from route signs and company names in the result images (pin it to a district/county before using it); finally compare bus photos from the candidate districts/counties one by one on rear end and waistline - Strength: medium (two sources, v006 and v014; can narrow a whole city down to one or two districts/counties); strong when route signs or company names are visible - Counterexamples: several liveries coexist in one city and change generations often; ad wraps cover the base colors; buses on cross-district routes run into other districts; not appearing in the first few pages of image results doesn't mean it doesn't exist - Sources: v006 v014 ## Infrastructure ### Electrified railway catenary masts: one side or both - Look for: a row of poles beside the railway with triangular cantilever arms at the top, holding two or three non-parallel wires (messenger wire, contact wire). Only on one side of the track → usually single-track; on both sides, or with portal frames spanning the tracks → double-track - Points to: an electrified railway; single-track electrified lines are a small share, so this can narrow down a lot (combine with regional clues, then list candidate lines with `osm.py` or a railway map) - Strength: medium - Counterexamples: seen from far away or from low down, masts on the other side may be hidden by trees or embankments; stations, bridges, and tunnels have different structures; "single-track = old line" does not hold - Sources: v011 ### Number of bundled conductors on transmission lines - Look for: how many conductors form one bundle per phase under the tower crossarm (with spacers between them) - Points to: voltage level. Common: single conductor ≈ below 220 kV, 2 → 220–330 kV, 4 → 500 kV, 6 → 750 kV or ±800 kV DC, 8 → 1000 kV - Strength: weak (alone it only tells the grid level); medium after intersecting with railways, rivers, expressways - Counterexamples: special designs; the count is easy to get wrong at a distance; several circuits in one corridor overlap - Sources: v011 v010-3 ### Combinations of special urban transport facilities - Look for: cross-river passenger cableways (two or three closely parallel track ropes + a haul rope, slanting from a riverside station to the opposite bank), road/rail-transit bridges (double-deck truss, rail transit on the lower deck), light rail running through a building, long urban escalators, high-speed rail viaducts - Points to: each facility maps to a set of cities; when two or three appear together, take the intersection (with a cross-river passenger cableway, a road/rail-transit bridge, and steep riverside mountains in one frame, usually only one or two mountain river cities nationwide remain); verify each item of the intersection with web search, not from memory - Strength: medium (combined, it can reach the city) - Counterexamples: high-voltage lines crossing a river (tall towers, insulators, nearly horizontal), cable-stayed bridge stays (fanning from the tower top), and dock crane cables get mistaken for cableways; road/railway bridges in Wuhan, Nanjing, and elsewhere are structurally similar but differ in color and terrain - Sources: v005 v010-3 ### Hu Hu Tong (户户通) small satellite dishes - Look for: small dishes 35–60 cm in diameter on rooftops in rural areas and urban villages - Points to: the heading corresponds to longitude: in eastern cities they face southwest (about 215°–227°), in the west they face south or southeast (table in `sky.md` section 5) - Strength: medium (when the azimuth is read accurately it can bound the longitude range) - Counterexamples: large dishes at institutions and cable TV stations point at other satellites; on offset-feed dishes the dish-face tilt is not the elevation angle - Sources: derived in sky.md; v001 (using dishes as a compass) ## Climate and phenology (must be paired with the month) ### Extensive evergreen broadleaf trees in winter photos - Look for: in photos from December–February, whether street trees, green belts, and hillsides at the city edge are broadly green (camphor, glossy privet, osmanthus, banyan and the like) - Points to: the subtropical evergreen broadleaf forest zone, roughly south of the Qinling–Huaihe line - Strength: medium with a reliable month (excludes half of China); weak without a month - Counterexamples: northern cities also have evergreen plantings such as pines, cypresses, and Japanese spindle (mostly conifers or shrubs); **the parts of northern provinces south of the Qinling are also evergreen in winter: Longnan in Gansu, Hanzhong and Ankang in Shaanxi**; high-altitude mountains in the south lose their leaves and hold snow in winter - Sources: v006 ### Color of mountains in winter (distant views, aerial) - Look for: in winter distant views or high-altitude photos, whether the mountains are uniformly gray-brown and bare or broadly green - Points to: gray-brown → temperate mountains north of the Qinling–Huaihe line; evergreen → southern subtropical mountains - Strength: medium (separates north from south; can't reach the province) - Counterexamples: conifer forest areas such as the northern Greater Khingan and the Changbai Mountains stay dark green in winter; the arid northwest is brown in every season; high altitudes in the south also turn withered yellow in winter; doesn't apply to summer photos - Sources: v012 v006 ### White water bodies seen from altitude - Look for: in aerial or airplane-window photos, water surfaces that are uniformly white with no reflection - Points to: ice or snow on ice → regions where the daily mean temperature stays below 0 ℃ for long stretches in winter, roughly north of the Qinling–Huaihe line - Strength: weak (pair with season and mountain color) - Counterexamples: salt lakes, salt pans, dry lakebeds, turbid sediment-laden water, sun glint, thin cloud; during cold waves in the south, small water bodies also freeze briefly - Sources: v012 ### Palms, coconut trees → "tropical China" - Look for: rows of coconut palms, palm-family plants, tropical landscaping - Points to: you must list every candidate: Hainan, southern Guangdong (Leizhou Peninsula), southern Guangxi, **southern Yunnan (river valleys in Xishuangbanna, Dehong, Honghe, etc.)**, southern Taiwan; beyond that, Southeast Asia - Strength: weak (only draws the candidate set) - Counterexamples: parks in Fujian, Sichuan, Shanghai, and elsewhere also plant palms; in v010-6 the AI searched only Hainan and Guangdong, missed Yunnan, and ended up jumping to Vietnam - Sources: v010-6 v002 ### Weeping willows + calm artificial water, no other features - Look for: weeping willows, mixed trees, water that looks like an irrigation canal or fish pond; no mountains, towers, road signs, or buildings - Points to: mainly provinces of the Huang-Huai-Hai Plain (Henan, Hebei, Shandong, Anhui, Jiangsu) - Strength: weak (only reaches a few provinces; photos like this can usually only give a range) - Counterexamples: the Yangtze basin and irrigated oases in the northwest also commonly have weeping willows and canals - Sources: v010-8 ### Fog, low cloud - Look for: low visibility, a gray-white sky, distant mountains reduced to outlines - Points to: common in Sichuan Basin cities (Chongqing etc.) in the winter half of the year - Strength: weak (only boosts; doesn't fix the city, let alone the solar term) - Counterexamples: smoggy winter days in North China and the Yangtze River Delta look the same; phone filters and backlighting also make things gray - Sources: v005 ## Terrain and water ### Mountains rising straight from the far bank of a big river, buildings stacked up the slopes - Look for: a very wide river with mountains several hundred meters high within one or two km of the far bank; houses climb the slope tier by tier - Points to: mountain river cities (central Chongqing, Yibin, Luzhou, and other Sichuan–Chongqing and upper-Yangtze cities); excludes plains cities on the middle and lower Yangtze (Wuhan, Jiujiang, Nanjing, Wuhu) - Strength: weak (excludes a large area; doesn't fix the city) - Counterexamples: Yichang and other cities also have nearby mountains across the river; fog "pulls" distant mountains closer, so check whether the mountain foot meets the built-up area directly - Sources: v005 ### Mountain city at the confluence of two rivers - Look for: the urban area straddles the confluence of two rivers, surrounded by mountains - Points to: a common layout in Sichuan–Chongqing and the southwest (Chongqing, Yibin, Luzhou, etc.); separate specific cities by confluence angle, river islands, bridge positions, and river width (template comparison in `corridors.md` section 6) - Strength: weak (common layout) - Counterexamples: river islands and shorelines change with water level and construction; mind the date of the satellite imagery - Sources: v006 ### Wide, muddy yellow river + low, dense, fragmented gullies - Look for: a river several hundred meters wide, earthy yellow; the hills on both banks are low but cut by gullies into branching patterns, sparse vegetation, terraces on the slopes - Points to: the Loess Plateau reach of the middle Yellow River (around the Shanxi–Shaanxi Gorge) and its tributaries in northern Shaanxi, western Shanxi, and eastern Gansu - Strength: medium (reaches Loess Plateau + Yellow River; pair with the river's direction and the terrain on both banks to get the reach) - Counterexamples: in flood season the Yangtze and the Jinsha are also yellow, but their banks are plains and high mountain gorges respectively; tributaries such as the Wuding and Yan rivers are just as muddy but much narrower - Sources: v008 ### Blue-green Yellow River surface in a canyon - Look for: a wide river surface, clear blue-green with no visible sediment, in a loess or red sandstone canyon - Points to: reservoir areas of the cascade dams on the upper Yellow River (Liujiaxia, Yanguoxia, Bapanxia in Gansu; Lijiaxia in Qinghai; Qingtongxia in Ningxia; etc.) - Strength: weak (only reaches the "upper-river reservoir" class of reach) - Counterexamples: season, light, and drone filters all change the water color; in v010-2 the AI used this clue to pick the most famous reservoir and was off by 7 km - Sources: v010-2 ## To be filled: street-level fingerprints between similar cities For several cities with similar layouts in the same basin or the same province, street-level distinguishing clues haven't been collected yet (guardrail color and style, bus shelters, curbs, traffic sign poles, streetlight design, residential facade colors). Until the library has them, when "several cities all look alike", sample and compare on the spot: ```bash uv run scripts/baidu_pano.py sample --bbox <city A urban area s,w,n,e> --n 24 --out cityA.jpg uv run scripts/baidu_pano.py sample --bbox <city B urban area s,w,n,e> --n 24 --out cityB.jpg ``` - Compare only things the city procures uniformly and that are consistent citywide (guardrails, shelters, sign poles); don't compare shop signs, trees, or cars. - Baidu street view was mostly captured several years ago, and fixtures like guardrails get replaced; when the sample year and the photo year are far apart, drop one tier. - A difference found by sampling must hold across three or more cities and two or more independent samples before it can be written into this section. ## Urban form ### No office clusters or supertall towers in the skyline - Look for: the frame is all residential high-rises, with no glass-curtain-wall office clusters or supertall landmarks - Points to: the old town of a prefecture-level city, or a county seat; not the core of a provincial capital - Strength: weak - Counterexamples: old districts of provincial capitals and suburban counties look like this too; the photo covers only one direction; the photographer may be standing on the tallest building in town - Sources: v006 ### Tile-clad factory buildings + rows of AC units, right next to new supertall towers (Pearl River Delta) - Look for: six- to eight-story factory buildings clad in small square beige or yellow-green tiles, windows lined with AC outdoor units; new glass-curtain-wall towers beside or behind them - Points to: "industrial-zone redevelopment" areas in Shenzhen, Dongguan, and similar places, where the old factories aren't demolished yet but new towers have gone up around them - Strength: weak (suggests the Pearl River Delta; can't fix the city on its own) - Counterexamples: old industrial areas in the Yangtze River Delta such as Suzhou and Ningbo also have similar factory buildings; tile color and AC unit density usually differ - Sources: case001 -
region.json 1.2 KB
{ "code": "CN", "name": "China (mainland)", "names": ["China", "Mainland China", "PRC", "People's Republic of China", "中国", "中国大陆", "中华人民共和国"], "status": "maintained", "maintainers": ["@Oldcircle"], "admin_levels": {"1": "admin1", "2": "admin2", "3": "district"}, "level_names": {"admin1": "province / municipality", "admin2": "prefecture", "district": "county / district"}, "name_suffixes": ["市", "省", "区", "县"], "admin_table": "data/admin.json", "lookups": { "plate": {"file": "data/plates.json"}, "area-code": {"file": "data/area_codes.json"} }, "docs": ["clues.md", "search.md"], "services": { "poi": {"sources": "so,osm,sug", "country": "cn"}, "street_view": "baidu_pano.py", "datum": "GCJ-02 on Chinese web maps; report WGS84 and GCJ-02 (geo.py convert)" }, "tips": { "livery": "bus/taxi livery → `revimg.py --query \"<城市> <颜色> 公交\"` (query in Chinese: <city> <color> bus); read route signs from the results, compare the rear waistline stripe district by district", "municipal": "guardrails, street lights, bus stops, curb styles → `baidu_pano.py sample --bbox <candidate built-up area> --n 24`, one contact sheet per candidate" } } -
search.md 2.7 KB
# Searching for places in mainland China Region-specific half of `references/search.md`: which Chinese platforms to search and how to phrase queries. The general method (variants, engines, vote counting, after a hit) stays in `references/search.md`. ## Chinese keyword search General web search tools are often ineffective for Chinese content inside China. Use the script: ```bash uv run scripts/revimg.py --query "蓝色拱形顶棚 人行天桥 高架" --query "<city> 出租车 颜色" --out-dir q/ ``` (Queries in Chinese: "blue arched canopy, pedestrian bridge, elevated road"; "<city> taxi color".) Bing China gives web results (title + link); Baidu Images and Sogou Images give result-page screenshots (look at photos of similar scenes). For long descriptive queries ("楼顶操场 学校" rooftop playground school, "黄色公交" yellow bus) Bing mostly returns travel-guide pages; look directly at the Baidu Images screenshot. Baidu web search pops up a verification challenge, so it isn't done. ## Where to search by object type (China) | Object | Where to search | Source | |---|---|---| | Scenic-area buildings, viral check-in spots | Douyin, Xiaohongshu, Weibo keyword and image search; official scenic-area accounts post videos from the same angle | v010-2, v009 | | Statues, small park features | User photos in Ctrip reviews | v010-6 | | New residential developments, commercial complexes | Housing-development albums on property sites (Anjuke, Fang.com, Loupan.com, etc.): the "周边配套" (nearby amenities) and "实景图" (real photos) sections; page all the way to the signboard | v010-5 | | Old buildings, historic sites | Local culture-and-tourism and protected-heritage-site pages; Visual China Group captions carry place names and years | v004 | | Ordinary streets, when even the city isn't fixed | Sample one page of arterial-road street view per candidate city and compare municipal fixtures (`baidu_pano.py sample`) | — | | Vehicle livery (bus, taxi, school bus) | `revimg.py --query "<city> <color description> 公交"` (query in Chinese: <city> <color description> bus), and read route signs and company names from the result images; first resolve any place name you read to a district (county) with `poi.py` before using it; don't treat a vehicle from district A as a clue for district B | v014 | ## Social media - Viral scenery: search `<city> + <scenery>` on Douyin, Xiaohongshu and Weibo, in both Chinese and English. - Locals' photos of the same mountains or the same river: look in the candidate township's "同城" (local) feed or its place page (the v010-1 creator spoofed the device location to the candidate township to browse local content). - IP location labels on posts and comments are covered in `clues.md` (Platform and metadata). -
tests.json 1.2 KB
[ {"kind": "plate", "value": "粤B·12345", "expect": [{"admin1": "广东省", "admin2": "深圳市"}]}, {"kind": "plate", "value": "川a", "expect": [{"admin1": "四川省", "admin2": "成都市"}]}, {"kind": "plate", "value": "渝G", "expect": [{"admin1": "重庆市"}]}, {"kind": "plate", "value": "渝", "expect": [{"admin1": "重庆市"}]}, {"kind": "plate", "value": "京A", "expect": [{"admin1": "北京市"}]}, {"kind": "plate", "value": "KA01AB1234", "expect": []}, {"kind": "area-code", "value": "0817-1234567", "expect": [{"admin1": "四川省", "admin2": "南充市"}]}, {"kind": "area-code", "value": "(020) 8888", "expect": [{"admin1": "广东省", "admin2": "广州市"}]}, {"kind": "area-code", "value": "023", "expect": [{"admin1": "重庆市"}]}, {"kind": "area-code", "value": "0520", "expect": [ {"admin1": "江苏省", "admin2": "苏州市", "district": "常熟市"}, {"admin1": "江苏省", "admin2": "苏州市", "district": "张家港市"}, {"admin1": "江苏省", "admin2": "苏州市", "district": "昆山市"}, {"admin1": "江苏省", "admin2": "苏州市", "district": "太仓市"}]}, {"kind": "area-code", "value": "13800138000", "expect": []} ]
-
-
il
-
data
-
admin.json 2.7 KB
{ "_meta": { "source": [ "https://en.wikipedia.org/wiki/Districts_of_Israel", "https://www.openstreetmap.org (boundary=administrative, admin_level=4; code = OSM relation id)" ], "fetched": "2026-10-13", "license": "CC BY-SA 4.0 (Wikipedia) / ODbL (OpenStreetMap)", "count": 19, "note": "The six districts (mehozot, level 4) and their subdistricts (nafot, level 5). Jerusalem and Tel Aviv districts each form a single subdistrict with the same boundary, which OSM does not map separately, so they have no level-5 rows; 13 + those 2 = the 15 subdistricts. The Judea and Samaria Area is administered separately and has no districts or subdistricts." }, "items": [ { "name": "מחוז ירושלים", "code": "1384720", "level": 4, "parent": "" }, { "name": "מחוז הצפון", "code": "1375620", "level": 4, "parent": "" }, { "name": "מחוז חיפה", "code": "1400966", "level": 4, "parent": "" }, { "name": "מחוז המרכז", "code": "1400928", "level": 4, "parent": "" }, { "name": "מחוז תל אביב", "code": "1400916", "level": 4, "parent": "" }, { "name": "מחוז הדרום", "code": "1473952", "level": 4, "parent": "" }, { "name": "נפת יזרעאל", "code": "11586982", "level": 5, "parent": "מחוז הצפון" }, { "name": "נפת כנרת", "code": "11586472", "level": 5, "parent": "מחוז הצפון" }, { "name": "נפת עכו", "code": "11586940", "level": 5, "parent": "מחוז הצפון" }, { "name": "נפת צפת", "code": "11586908", "level": 5, "parent": "מחוז הצפון" }, { "name": "נפת רמת הגולן", "code": "11585174", "level": 5, "parent": "מחוז הצפון" }, { "name": "נפת חדרה", "code": "11584745", "level": 5, "parent": "מחוז חיפה" }, { "name": "נפת חיפה", "code": "11584519", "level": 5, "parent": "מחוז חיפה" }, { "name": "נפת השרון", "code": "11587694", "level": 5, "parent": "מחוז המרכז" }, { "name": "נפת פתח תקווה", "code": "11587672", "level": 5, "parent": "מחוז המרכז" }, { "name": "נפת רחובות", "code": "11587514", "level": 5, "parent": "מחוז המרכז" }, { "name": "נפת רמלה", "code": "11587600", "level": 5, "parent": "מחוז המרכז" }, { "name": "נפת אשקלון", "code": "11584353", "level": 5, "parent": "מחוז הדרום" }, { "name": "נפת באר שבע", "code": "11584306", "level": 5, "parent": "מחוז הדרום" } ] } -
area_codes.json 2.6 KB
{ "_meta": { "kind": "area-code", "normalize": { "digits_only": true, "ensure_prefix": "0" }, "key_pattern": "^0[23489](?=\\d{7}$)", "usage": "full landline number in national form (9 digits): 02-623-4567, (04) 812-3456; drop a leading +972. A bare code is not accepted, so other countries' codes don't match", "note": "Only five geographic codes, and a code's area does not follow district borders: each key lists every district it reaches, including districts it covers only in part. Checked on 2026-10-14 against OpenStreetMap: the 1,700 Israeli landline numbers in phone tags were placed in the six district polygons and counted by code; codes seen only on chain head offices and call centres were not counted as coverage. Numbers in the Judea and Samaria Area (02, 03, 08, 09) have no district in the admin table.", "miss_note": "not an Israeli geographic code: 05x mobile, 07x VoIP/non-geographic, 1-700/1-800 numbers", "source": [ "https://en.wikipedia.org/wiki/Telephone_numbers_in_Israel", "https://www.openstreetmap.org (phone=* on nodes, ways and relations, placed in boundary=administrative admin_level=4 polygons)", "https://www.allschool.co.il/מוסדות/410266/האלה-ממד-נחושה (HaEla school, Nehusha, Mateh Yehuda, Jerusalem District: 08-6815269)" ], "fetched": "2026-10-14", "license": "Derived from Wikipedia (CC BY-SA 4.0) and OpenStreetMap (ODbL); a phone directory entry used as a fact, not copied", "count": 5 }, "entries": { "02": [ { "admin1": "מחוז ירושלים", "note": "Jerusalem District area; the same digit is also used in the Palestinian territories under +970" } ], "03": [ { "admin1": "מחוז תל אביב" }, { "admin1": "מחוז המרכז", "note": "Gush Dan part of the Central District (e.g. Petah Tikva, Rishon LeZion)" } ], "04": [ { "admin1": "מחוז חיפה" }, { "admin1": "מחוז הצפון" } ], "08": [ { "admin1": "מחוז הדרום", "note": "the same digit is also used in the Palestinian territories under +970" }, { "admin1": "מחוז המרכז", "note": "Shephelah (lowland) part of the Central District (e.g. Rehovot, Ramla, Modiin)" }, { "admin1": "מחוז ירושלים", "note": "only the western part of the district, in Mateh Yehuda regional council (e.g. Nehusha); the city of Jerusalem and Beit Shemesh are 02" } ], "09": [ { "admin1": "מחוז המרכז", "note": "Sharon part of the Central District (e.g. Netanya, Kfar Saba)" }, { "admin1": "מחוז תל אביב", "note": "northern edge (e.g. Herzliya)" } ] } }
-
-
clues.md 1.8 KB
# Israel clues Entries use the shared format (`references/clues/README.md`). Clues that tell Israel apart from its neighbours belong in `references/clues/global.md`. ## Text and numbers ### Landline area code - Look for: a fixed-line number on a shop sign or vehicle: `0X-XXX-XXXX` with X = 2, 3, 4, 8 or 9 - Points to: 02 Jerusalem area; 03 Tel Aviv and Gush Dan; 04 Haifa and the north; 08 the Shephelah and the south; 09 the Sharon → `clues.py lookup area-code <number> --country il` - Strength: weak (five codes for the whole country; each covers several districts) - Counterexamples: `05x` is mobile and `07x` is VoIP, both used nationwide; `1-700`/`1-800` business numbers; the same digits are used in the West Bank and Gaza under +970 - Sources: en.wikipedia "Telephone numbers in Israel", section "Geographic area codes", https://en.wikipedia.org/wiki/Telephone_numbers_in_Israel ## Roads ### Road number: digit count, parity and size - Look for: the route number on a road sign or route shield - Points to: digit count gives the class (1 national, 2 inter-city, 3 regional, 4 local). Even numbers run south–north, odd numbers west–east. Among roads with the same number of digits, numbers rise from south to north (west–east roads) and from west to east (south–north roads): 1x roads are in the far south, 9x roads in the far north-east. Pick the band, then `osm.py route` along that road - Strength: medium (two sources) - Counterexamples: there are exceptions (Highway 1 lies mostly north of Highway 3); roads in the West Bank and Golan Heights use the same scheme - Sources: en.wikipedia "Roads in Israel", section "Numbering of the roads", https://en.wikipedia.org/wiki/Roads_in_Israel; he.wikipedia "כבישי ישראל" (even = south–north, odd = west–east, numbers rise northward and eastward), https://he.wikipedia.org/wiki/כבישי_ישראל -
region.json 616 B
{ "code": "IL", "name": "Israel", "names": [ "Israel", "ישראל", "إسرائيل", "State of Israel", "IL" ], "status": "community", "maintainers": [ "@mstkyvz" ], "admin_levels": { "4": "admin1", "5": "admin2" }, "level_names": { "admin1": "district (mahoz)", "admin2": "subdistrict (nafa)" }, "name_suffixes": [ "מחוז", "נפת" ], "admin_table": "data/admin.json", "lookups": { "area-code": { "file": "data/area_codes.json" } }, "docs": [ "clues.md" ], "services": { "poi": { "sources": "osm", "country": "il" }, "street_view": "gsv.py" } } -
tests.json 1 KB
[ { "kind": "area-code", "value": "02-6234567", "expect": [ { "admin1": "מחוז ירושלים" } ] }, { "kind": "area-code", "value": "08-6602250", "expect": [ { "admin1": "מחוז הדרום" }, { "admin1": "מחוז המרכז" }, { "admin1": "מחוז ירושלים" } ] }, { "kind": "area-code", "value": "03-9051234", "expect": [ { "admin1": "מחוז תל אביב" }, { "admin1": "מחוז המרכז" } ] }, { "kind": "area-code", "value": "(04) 812-3456", "expect": [ { "admin1": "מחוז חיפה" }, { "admin1": "מחוז הצפון" } ] }, { "kind": "area-code", "value": "09-7654321", "expect": [ { "admin1": "מחוז המרכז" }, { "admin1": "מחוז תל אביב" } ] }, { "kind": "area-code", "value": "052-1234567", "expect": [] }, { "kind": "area-code", "value": "077-1234567", "expect": [] }, { "kind": "area-code", "value": "0817-1234567", "expect": [] } ]
-
-
jp
-
clues.md 2.3 KB
# Japan clues Entries use the shared format (`references/clues/README.md`). Cross-country clues that lead here (driving side, plate shape, script) are in `references/clues/global.md`. ## Clues ### Railway company names on level-crossing and station signs - Look for: the company name printed on round "踏切 とまれ" (level crossing, stop) signs, warning signs, and crossing equipment boxes - Points to: operating area. JR passenger service is split among six companies, each with its own territory (JR西日本 (JR West) = Kinki, the Chūgoku region, part of Hokuriku); private railway names can often pin the line directly - Strength: medium (JR to the broad region, private railways to the line) - Counterexamples: near company boundaries; old signs not replaced after a line was handed over to a third-party operator - Sources: v003 ### Telephone area codes (市外局番) - Look for: TEL numbers on signs, construction notices, warning signs, vending machines - Points to: the first digit after the 0 runs roughly north to south (01 Hokkaido … 09 Kyushu and Okinawa); look up the full area code in a table to get the city (e.g., 03 = Tokyo's 23 wards) - Strength: medium - Counterexamples: area codes vary from 2 to 5 digits; when only part of the number is visible, judge the length from the digit grouping; 0120, 0570, 050, 080, 090 don't map to a region - Sources: v003 ### Four parallel tracks at one level crossing - Look for: the number of parallel tracks across the crossing, whether there is overhead catenary - Points to: a quadruple-track section of a trunk line; adding conditions like "right on the coast" can narrow it to a few km (`osm.py crossings --kind level_crossing` estimates the track count from the node count) - Strength: medium - Counterexamples: station throats and two companies' lines running side by side also show 4 tracks; urban quadruple-track sections are mostly grade-separated, so places with level crossings are actually rare - Sources: v003 ### Issuing authority at the bottom of signs - Look for: "〇〇警察署" (police station), "〇〇土木事務所" (civil engineering office), "〇〇区役所" (ward office) at the bottom of regulatory signs, construction signs, and notice boards - Points to: ward, city - Strength: medium - Counterexamples: the name of a prefectural or cross-ward agency is not the ward you are in - Sources: v003 -
region.json 149 B
{ "code": "JP", "name": "Japan", "names": [ "Japan", "日本", "JP" ], "status": "stub", "maintainers": [], "docs": [ "clues.md" ] }
-
-
lk
-
clues.md 2 KB
# Sri Lanka clues Entries use the shared format (`references/clues/README.md`). Cross-country clues that lead here (Sinhala script, driving side) are in `references/clues/global.md`. ## Text and plates ### Province code on number plates - Look for: two small Latin letters below the emblem on the left side of the plate, before the main registration (e.g. `WP CAB-1234`). Codes: WP Western, CP Central, SP Southern, NP Northern, EP Eastern, NW North Western, NC North Central, UP Uva, SG Sabaragamuwa - Points to: the province where the vehicle was **registered**, not where the photo was taken - Strength: weak for the photo location (registration province only; Western Province cars are common across the island) - Counterexamples: vehicles travel and are sold across provinces, and existing plates often keep their code even after a sale; plates in the older format (e.g. `ශ්රී` between the numbers) have no code; the code is being removed from new plates (announced for new registrations and ownership transfers from 1 January 2023; gazette No. 2352/29 of 5 October 2023; a 2024 report still describes the full removal as planned from 1 January 2025; a 2026 report says the codes "have now been removed"), so the actual cutover was gradual and a plate without a code says little about its age - Sources: Wikipedia "Vehicle registration plates of Sri Lanka" (format, codes, position), https://en.wikipedia.org/wiki/Vehicle_registration_plates_of_Sri_Lanka; Ada Derana 2022-12-30 (Commissioner General of Motor Traffic: removal from 1 January 2023), https://www.adaderana.lk/news/87222/provincial-letters-to-be-removed-from-number-plates-; Sunday Observer 2024-05-05 (gazette 2352/29, requirement dropped from 1 January 2025), https://www.sundayobserver.lk/2024/05/05/news-features/22447/why-the-province-is-being-dropped-from-number-plates; Ada Derana 2026-04-23 (identifiers removed), https://www.adaderana.lk/news/121629/standard-vehicle-number-plates-to-be-issued-from-late-may -
region.json 240 B
{ "code": "LK", "name": "Sri Lanka", "names": [ "Sri Lanka", "ශ්රී ලංකාව", "இலங்கை", "Ceylon", "LK" ], "status": "community", "maintainers": [ "@Yasas-Sri" ], "docs": [ "clues.md" ] }
-
-
mm
-
clues.md 509 B
# Myanmar clues Entries use the shared format (`references/clues/README.md`). Cross-country clues that lead here (driving side, plate shape, script) are in `references/clues/global.md`. ## Clues ### Region/state codes on Myanmar plates - Look for: a Latin-letter abbreviation on the plate (e.g., YGN) plus a number - Points to: Myanmar; YGN = Yangon Region - Strength: medium (single source) - Counterexamples: vehicles from other regions; old plates from before 2013 are in Burmese script - Sources: v007 -
region.json 164 B
{ "code": "MM", "name": "Myanmar", "names": [ "Myanmar", "Burma", "缅甸", "MM" ], "status": "stub", "maintainers": [], "docs": [ "clues.md" ] }
-
-
mx
-
clues.md 1.3 KB
# Mexico clues Entries use the shared format (`references/clues/README.md`). Cross-country clues that lead here (driving side, plate shape, script) are in `references/clues/global.md`. ## Clues ### Plate background color (usable even when blurry) - Look for: the overall background color and graphic placement of the rear plate; no need to read the characters - Points to: state level. Per the reference chart used in the video, nearly pure white plates are Mexico City and Nuevo León; most other states carry prominent colored graphics or color bands - Strength: medium (single source) - Counterexamples: vehicles from other states; states change plate designs; federal plates are a separate system; overexposure - Sources: v002 ### Tall single tubular steel transmission poles along roads - Look for: a gray single tubular steel pole with three tiers of curved crossarms on one side and long insulator strings; the shorter poles beside it carry distribution lines - Points to: a high-voltage transmission corridor; check OpenInfraMap for which roads lines of this voltage follow in that city, and use that to filter road segments within the city - Strength: weak (can't fix the city) - Counterexamples: different voltages look similar; OSM power data may be missing lines or lack voltage tags - Sources: v002 -
region.json 167 B
{ "code": "MX", "name": "Mexico", "names": [ "Mexico", "México", "墨西哥", "MX" ], "status": "stub", "maintainers": [], "docs": [ "clues.md" ] }
-
-
tr
-
data
-
admin.json 6.6 KB
{ "_meta": { "source": [ "https://tr.wikipedia.org/wiki/Türkiye'nin_illeri", "https://www.openstreetmap.org (boundary=administrative, admin_level=4 names)" ], "fetched": "2026-10-13", "license": "CC BY-SA 4.0 (Wikipedia) / ODbL (OpenStreetMap names)", "count": 81 }, "items": [ { "name": "Adana", "code": "01", "level": 4, "parent": "" }, { "name": "Adıyaman", "code": "02", "level": 4, "parent": "" }, { "name": "Afyonkarahisar", "code": "03", "level": 4, "parent": "" }, { "name": "Ağrı", "code": "04", "level": 4, "parent": "" }, { "name": "Amasya", "code": "05", "level": 4, "parent": "" }, { "name": "Ankara", "code": "06", "level": 4, "parent": "" }, { "name": "Antalya", "code": "07", "level": 4, "parent": "" }, { "name": "Artvin", "code": "08", "level": 4, "parent": "" }, { "name": "Aydın", "code": "09", "level": 4, "parent": "" }, { "name": "Balıkesir", "code": "10", "level": 4, "parent": "" }, { "name": "Bilecik", "code": "11", "level": 4, "parent": "" }, { "name": "Bingöl", "code": "12", "level": 4, "parent": "" }, { "name": "Bitlis", "code": "13", "level": 4, "parent": "" }, { "name": "Bolu", "code": "14", "level": 4, "parent": "" }, { "name": "Burdur", "code": "15", "level": 4, "parent": "" }, { "name": "Bursa", "code": "16", "level": 4, "parent": "" }, { "name": "Çanakkale", "code": "17", "level": 4, "parent": "" }, { "name": "Çankırı", "code": "18", "level": 4, "parent": "" }, { "name": "Çorum", "code": "19", "level": 4, "parent": "" }, { "name": "Denizli", "code": "20", "level": 4, "parent": "" }, { "name": "Diyarbakır", "code": "21", "level": 4, "parent": "" }, { "name": "Edirne", "code": "22", "level": 4, "parent": "" }, { "name": "Elâzığ", "code": "23", "level": 4, "parent": "" }, { "name": "Erzincan", "code": "24", "level": 4, "parent": "" }, { "name": "Erzurum", "code": "25", "level": 4, "parent": "" }, { "name": "Eskişehir", "code": "26", "level": 4, "parent": "" }, { "name": "Gaziantep", "code": "27", "level": 4, "parent": "" }, { "name": "Giresun", "code": "28", "level": 4, "parent": "" }, { "name": "Gümüşhane", "code": "29", "level": 4, "parent": "" }, { "name": "Hakkâri", "code": "30", "level": 4, "parent": "" }, { "name": "Hatay", "code": "31", "level": 4, "parent": "" }, { "name": "Isparta", "code": "32", "level": 4, "parent": "" }, { "name": "Mersin", "code": "33", "level": 4, "parent": "" }, { "name": "İstanbul", "code": "34", "level": 4, "parent": "" }, { "name": "İzmir", "code": "35", "level": 4, "parent": "" }, { "name": "Kars", "code": "36", "level": 4, "parent": "" }, { "name": "Kastamonu", "code": "37", "level": 4, "parent": "" }, { "name": "Kayseri", "code": "38", "level": 4, "parent": "" }, { "name": "Kırklareli", "code": "39", "level": 4, "parent": "" }, { "name": "Kırşehir", "code": "40", "level": 4, "parent": "" }, { "name": "Kocaeli", "code": "41", "level": 4, "parent": "" }, { "name": "Konya", "code": "42", "level": 4, "parent": "" }, { "name": "Kütahya", "code": "43", "level": 4, "parent": "" }, { "name": "Malatya", "code": "44", "level": 4, "parent": "" }, { "name": "Manisa", "code": "45", "level": 4, "parent": "" }, { "name": "Kahramanmaraş", "code": "46", "level": 4, "parent": "" }, { "name": "Mardin", "code": "47", "level": 4, "parent": "" }, { "name": "Muğla", "code": "48", "level": 4, "parent": "" }, { "name": "Muş", "code": "49", "level": 4, "parent": "" }, { "name": "Nevşehir", "code": "50", "level": 4, "parent": "" }, { "name": "Niğde", "code": "51", "level": 4, "parent": "" }, { "name": "Ordu", "code": "52", "level": 4, "parent": "" }, { "name": "Rize", "code": "53", "level": 4, "parent": "" }, { "name": "Sakarya", "code": "54", "level": 4, "parent": "" }, { "name": "Samsun", "code": "55", "level": 4, "parent": "" }, { "name": "Siirt", "code": "56", "level": 4, "parent": "" }, { "name": "Sinop", "code": "57", "level": 4, "parent": "" }, { "name": "Sivas", "code": "58", "level": 4, "parent": "" }, { "name": "Tekirdağ", "code": "59", "level": 4, "parent": "" }, { "name": "Tokat", "code": "60", "level": 4, "parent": "" }, { "name": "Trabzon", "code": "61", "level": 4, "parent": "" }, { "name": "Tunceli", "code": "62", "level": 4, "parent": "" }, { "name": "Şanlıurfa", "code": "63", "level": 4, "parent": "" }, { "name": "Uşak", "code": "64", "level": 4, "parent": "" }, { "name": "Van", "code": "65", "level": 4, "parent": "" }, { "name": "Yozgat", "code": "66", "level": 4, "parent": "" }, { "name": "Zonguldak", "code": "67", "level": 4, "parent": "" }, { "name": "Aksaray", "code": "68", "level": 4, "parent": "" }, { "name": "Bayburt", "code": "69", "level": 4, "parent": "" }, { "name": "Karaman", "code": "70", "level": 4, "parent": "" }, { "name": "Kırıkkale", "code": "71", "level": 4, "parent": "" }, { "name": "Batman", "code": "72", "level": 4, "parent": "" }, { "name": "Şırnak", "code": "73", "level": 4, "parent": "" }, { "name": "Bartın", "code": "74", "level": 4, "parent": "" }, { "name": "Ardahan", "code": "75", "level": 4, "parent": "" }, { "name": "Iğdır", "code": "76", "level": 4, "parent": "" }, { "name": "Yalova", "code": "77", "level": 4, "parent": "" }, { "name": "Karabük", "code": "78", "level": 4, "parent": "" }, { "name": "Kilis", "code": "79", "level": 4, "parent": "" }, { "name": "Osmaniye", "code": "80", "level": 4, "parent": "" }, { "name": "Düzce", "code": "81", "level": 4, "parent": "" } ] } -
area_codes.json 4.8 KB
{ "_meta": { "kind": "area-code", "normalize": { "digits_only": true, "ensure_prefix": "0" }, "usage": "landline area code in national form: 0212, (0232) 444 55 66, 322 123 45 67; drop a leading +90", "miss_note": "not a Turkish province landline code: 05xx mobile, 0850/0800/0444 nationwide numbers, or 0392 (Northern Cyprus, dialled through +90)", "source": [ "https://tr.wikipedia.org/wiki/Türkiye'nin_illeri", "https://en.wikipedia.org/wiki/Telephone_numbers_in_Turkey" ], "fetched": "2026-10-13", "license": "Derived from Wikipedia, CC BY-SA 4.0", "count": 82 }, "entries": { "0212": [ { "admin1": "İstanbul", "note": "European side of İstanbul (Thrace)" } ], "0216": [ { "admin1": "İstanbul", "note": "Asian (Anatolian) side of İstanbul" } ], "0222": [ { "admin1": "Eskişehir" } ], "0224": [ { "admin1": "Bursa" } ], "0226": [ { "admin1": "Yalova" } ], "0228": [ { "admin1": "Bilecik" } ], "0232": [ { "admin1": "İzmir" } ], "0236": [ { "admin1": "Manisa" } ], "0242": [ { "admin1": "Antalya" } ], "0246": [ { "admin1": "Isparta" } ], "0248": [ { "admin1": "Burdur" } ], "0252": [ { "admin1": "Muğla" } ], "0256": [ { "admin1": "Aydın" } ], "0258": [ { "admin1": "Denizli" } ], "0262": [ { "admin1": "Kocaeli" } ], "0264": [ { "admin1": "Sakarya" } ], "0266": [ { "admin1": "Balıkesir" } ], "0272": [ { "admin1": "Afyonkarahisar" } ], "0274": [ { "admin1": "Kütahya" } ], "0276": [ { "admin1": "Uşak" } ], "0282": [ { "admin1": "Tekirdağ" } ], "0284": [ { "admin1": "Edirne" } ], "0286": [ { "admin1": "Çanakkale" } ], "0288": [ { "admin1": "Kırklareli" } ], "0312": [ { "admin1": "Ankara" } ], "0318": [ { "admin1": "Kırıkkale" } ], "0322": [ { "admin1": "Adana" } ], "0324": [ { "admin1": "Mersin" } ], "0326": [ { "admin1": "Hatay" } ], "0328": [ { "admin1": "Osmaniye" } ], "0332": [ { "admin1": "Konya" } ], "0338": [ { "admin1": "Karaman" } ], "0342": [ { "admin1": "Gaziantep" } ], "0344": [ { "admin1": "Kahramanmaraş" } ], "0346": [ { "admin1": "Sivas" } ], "0348": [ { "admin1": "Kilis" } ], "0352": [ { "admin1": "Kayseri" } ], "0354": [ { "admin1": "Yozgat" } ], "0356": [ { "admin1": "Tokat" } ], "0358": [ { "admin1": "Amasya" } ], "0362": [ { "admin1": "Samsun" } ], "0364": [ { "admin1": "Çorum" } ], "0366": [ { "admin1": "Kastamonu" } ], "0368": [ { "admin1": "Sinop" } ], "0370": [ { "admin1": "Karabük" } ], "0372": [ { "admin1": "Zonguldak" } ], "0374": [ { "admin1": "Bolu" } ], "0376": [ { "admin1": "Çankırı" } ], "0378": [ { "admin1": "Bartın" } ], "0380": [ { "admin1": "Düzce" } ], "0382": [ { "admin1": "Aksaray" } ], "0384": [ { "admin1": "Nevşehir" } ], "0386": [ { "admin1": "Kırşehir" } ], "0388": [ { "admin1": "Niğde" } ], "0412": [ { "admin1": "Diyarbakır" } ], "0414": [ { "admin1": "Şanlıurfa" } ], "0416": [ { "admin1": "Adıyaman" } ], "0422": [ { "admin1": "Malatya" } ], "0424": [ { "admin1": "Elâzığ" } ], "0426": [ { "admin1": "Bingöl" } ], "0428": [ { "admin1": "Tunceli" } ], "0432": [ { "admin1": "Van" } ], "0434": [ { "admin1": "Bitlis" } ], "0436": [ { "admin1": "Muş" } ], "0438": [ { "admin1": "Hakkâri" } ], "0442": [ { "admin1": "Erzurum" } ], "0446": [ { "admin1": "Erzincan" } ], "0452": [ { "admin1": "Ordu" } ], "0454": [ { "admin1": "Giresun" } ], "0456": [ { "admin1": "Gümüşhane" } ], "0458": [ { "admin1": "Bayburt" } ], "0462": [ { "admin1": "Trabzon" } ], "0464": [ { "admin1": "Rize" } ], "0466": [ { "admin1": "Artvin" } ], "0472": [ { "admin1": "Ağrı" } ], "0474": [ { "admin1": "Kars" } ], "0476": [ { "admin1": "Iğdır" } ], "0478": [ { "admin1": "Ardahan" } ], "0482": [ { "admin1": "Mardin" } ], "0484": [ { "admin1": "Siirt" } ], "0486": [ { "admin1": "Şırnak" } ], "0488": [ { "admin1": "Batman" } ] } } -
plates.json 4.9 KB
{ "_meta": { "kind": "plate", "normalize": { "strip": " -·.", "upper": true }, "key_pattern": "^\\d{2}(?=([A-Z]{1,3}\\d{2,5}|\\d{2})?$)", "usage": "a whole civil plate (34 ABC 123), just the two digits (34), or a provincial road number (34-03)", "note": "The code is where the vehicle was registered, not where the photo was taken. 01–67 follow the alphabetical order of 1962 province names; 68–81 went to provinces created later.", "miss_note": "not 01–81: misread digits, a foreign plate, or not a civil plate", "source": [ "https://tr.wikipedia.org/wiki/Türkiye_taşıt_plaka_kodu", "https://tr.wikipedia.org/wiki/Türkiye'nin_illeri" ], "fetched": "2026-10-13", "license": "Derived from Wikipedia, CC BY-SA 4.0", "count": 81 }, "entries": { "01": [ { "admin1": "Adana" } ], "02": [ { "admin1": "Adıyaman" } ], "03": [ { "admin1": "Afyonkarahisar" } ], "04": [ { "admin1": "Ağrı" } ], "05": [ { "admin1": "Amasya" } ], "06": [ { "admin1": "Ankara" } ], "07": [ { "admin1": "Antalya" } ], "08": [ { "admin1": "Artvin" } ], "09": [ { "admin1": "Aydın" } ], "10": [ { "admin1": "Balıkesir" } ], "11": [ { "admin1": "Bilecik" } ], "12": [ { "admin1": "Bingöl" } ], "13": [ { "admin1": "Bitlis" } ], "14": [ { "admin1": "Bolu" } ], "15": [ { "admin1": "Burdur" } ], "16": [ { "admin1": "Bursa" } ], "17": [ { "admin1": "Çanakkale" } ], "18": [ { "admin1": "Çankırı" } ], "19": [ { "admin1": "Çorum" } ], "20": [ { "admin1": "Denizli" } ], "21": [ { "admin1": "Diyarbakır" } ], "22": [ { "admin1": "Edirne" } ], "23": [ { "admin1": "Elâzığ" } ], "24": [ { "admin1": "Erzincan" } ], "25": [ { "admin1": "Erzurum" } ], "26": [ { "admin1": "Eskişehir" } ], "27": [ { "admin1": "Gaziantep" } ], "28": [ { "admin1": "Giresun" } ], "29": [ { "admin1": "Gümüşhane" } ], "30": [ { "admin1": "Hakkâri" } ], "31": [ { "admin1": "Hatay" } ], "32": [ { "admin1": "Isparta" } ], "33": [ { "admin1": "Mersin", "note": "registered as İçel until the province was renamed Mersin in 2002" } ], "34": [ { "admin1": "İstanbul" } ], "35": [ { "admin1": "İzmir" } ], "36": [ { "admin1": "Kars" } ], "37": [ { "admin1": "Kastamonu" } ], "38": [ { "admin1": "Kayseri" } ], "39": [ { "admin1": "Kırklareli" } ], "40": [ { "admin1": "Kırşehir" } ], "41": [ { "admin1": "Kocaeli" } ], "42": [ { "admin1": "Konya" } ], "43": [ { "admin1": "Kütahya" } ], "44": [ { "admin1": "Malatya" } ], "45": [ { "admin1": "Manisa" } ], "46": [ { "admin1": "Kahramanmaraş", "note": "the code dates from 1962, when the province was Maraş" } ], "47": [ { "admin1": "Mardin" } ], "48": [ { "admin1": "Muğla" } ], "49": [ { "admin1": "Muş" } ], "50": [ { "admin1": "Nevşehir" } ], "51": [ { "admin1": "Niğde" } ], "52": [ { "admin1": "Ordu" } ], "53": [ { "admin1": "Rize" } ], "54": [ { "admin1": "Sakarya" } ], "55": [ { "admin1": "Samsun" } ], "56": [ { "admin1": "Siirt" } ], "57": [ { "admin1": "Sinop" } ], "58": [ { "admin1": "Sivas" } ], "59": [ { "admin1": "Tekirdağ" } ], "60": [ { "admin1": "Tokat" } ], "61": [ { "admin1": "Trabzon" } ], "62": [ { "admin1": "Tunceli" } ], "63": [ { "admin1": "Şanlıurfa", "note": "the code dates from 1962, when the province was Urfa" } ], "64": [ { "admin1": "Uşak" } ], "65": [ { "admin1": "Van" } ], "66": [ { "admin1": "Yozgat" } ], "67": [ { "admin1": "Zonguldak" } ], "68": [ { "admin1": "Aksaray" } ], "69": [ { "admin1": "Bayburt" } ], "70": [ { "admin1": "Karaman" } ], "71": [ { "admin1": "Kırıkkale" } ], "72": [ { "admin1": "Batman" } ], "73": [ { "admin1": "Şırnak" } ], "74": [ { "admin1": "Bartın" } ], "75": [ { "admin1": "Ardahan" } ], "76": [ { "admin1": "Iğdır" } ], "77": [ { "admin1": "Yalova" } ], "78": [ { "admin1": "Karabük" } ], "79": [ { "admin1": "Kilis" } ], "80": [ { "admin1": "Osmaniye" } ], "81": [ { "admin1": "Düzce" } ] } }
-
-
clues.md 6 KB
# Turkey clues Entries use the shared format (`references/clues/README.md`). Clues that tell Turkey apart from other countries belong in `references/clues/global.md`. ## Text and numbers ### Province code at the start of a civil number plate - Look for: the two digits before the letters on a white civil plate (`34 ABC 123`, `06 AB 1234`); the blue band on the left reads `TR` - Points to: the province where the vehicle was **registered** → `clues.py lookup plate <digits> --country tr` or `board.py apply --kind plate --value <digits>` - Strength: weak for where the photo was taken; medium when most parked cars in a quiet residential street share one code - Counterexamples: rental and fleet cars are often registered in 34 İstanbul, 06 Ankara, 07 Antalya or 35 İzmir and driven anywhere; holiday traffic carries plates far from home; 33, 46 and 63 date from the old names İçel, Maraş and Urfa; some owners cover the blue band with a red sticker - Sources: tr.wikipedia "Türkiye taşıt plaka kodu" (codes, 1962 alphabetical order, 68–81 for later provinces), https://tr.wikipedia.org/wiki/Türkiye_taşıt_plaka_kodu; tr.wikipedia "Türkiye'nin illeri" (code per province), https://tr.wikipedia.org/wiki/Türkiye'nin_illeri ### Letter group of an older plate → district tax office - Look for: on plates issued before sequential allocation, the first letter(s) after the province code (Ankara `06 P..` Polatlı, `06 N..` Nallıhan; Edirne `22 K..` Havsa, `22 L..` Uzunköprü, `22 F..` Keşan) - Points to: the district (ilçe) tax office where the vehicle was registered - Strength: weak (single source) - Counterexamples: in recent years every province hands out `AAA 999` to `ZZZ 999` in sequence, so new plates say nothing about the district; the letter scheme differs per province and is not published as one table; school and staff-shuttle plates use reserved letters - Sources: tr.wikipedia "Türkiye taşıt plaka kodu", section "Plaka türleri", https://tr.wikipedia.org/wiki/Türkiye_taşıt_plaka_kodu ### Landline area code on shop signs and vehicles - Look for: a fixed-line number on a shop sign, awning, delivery van or banner: `0 (2xx)`, `0 (3xx)` or `0 (4xx)` followed by seven digits - Points to: the province of that landline → `clues.py lookup area-code <number> --country tr`; in İstanbul, 212 is the European side and 216 the Asian side - Strength: medium (a shop's own landline is usually local; a single source of the code list is enough here because two tables agree) - Counterexamples: `0850`, `0800` and `444 xx xx` are nationwide numbers; `05xx` numbers are mobile; a chain's sign can show its head-office number; `0392` is Northern Cyprus, which is dialled through +90 but is not a Turkish province; old numbers survive on faded signs after a business moves - Sources: tr.wikipedia "Türkiye'nin illeri" (telephone code column), https://tr.wikipedia.org/wiki/Türkiye'nin_illeri; en.wikipedia "Telephone numbers in Turkey" (212/216 split, full table), https://en.wikipedia.org/wiki/Telephone_numbers_in_Turkey ## Roads ### Provincial road number = plate code + two digits - Look for: a road number in the form `NN-NN` on a route marker or kilometre post (`34-03`, `04-28`) - Points to: the first two digits are the province's plate code → `clues.py lookup plate <first two digits> --country tr`; the road is a secondary road maintained by that province's highways division - Strength: medium (single source) - Counterexamples: a sign at a junction can name a road that starts in the next province; misreading a state road `D 300` as a provincial number - Sources: tr.wikipedia "Türkiye'deki il yolları listesi" (every listed road starts with its province's plate code), https://tr.wikipedia.org/wiki/Türkiye'deki_il_yolları_listesi ### State road numbers: first digit → direction and band - Look for: a state road number `D 010` … `D 990` on route markers and kilometre posts - Points to: `D 0xx`–`D 4xx` run west–east and are numbered from north to south (`D 010` along the Black Sea coast, `D 400` along the south); `D 5xx`–`D 9xx` run north–south and are numbered from west to east. Use the number to pick a latitude band (west–east roads) or a longitude band (north–south roads), then `osm.py route` or `osm.py along` on that road - Strength: medium (single source) - Counterexamples: branch roads (`D 014`, `D 052`) are short spurs that may lie away from the main road's band; overlapping routes share a carriageway and only one number may be signed - Sources: tr.wikipedia "Türkiye'deki devlet yolları listesi", section "Yolların numaralandırılması", https://tr.wikipedia.org/wiki/Türkiye'deki_devlet_yolları_listesi ## Landscape and crops ### Tea gardens on steep slopes - Look for: low, clipped, dark-green bushes in tight rows covering steep hillsides, often with small houses scattered between them - Points to: the eastern Black Sea coast, Rize above all, with Artvin, Trabzon and Giresun - Strength: weak by default, as for any vegetation without a month; medium when the month is known and the bushes show the clipped, flat-topped rows of a harvested plantation (Turkey's tea is grown almost only here) - Counterexamples: clipped hedges and boxwood plantings in gardens and parks; Georgia's Adjara coast across the border has the same landscape - Sources: en.wikipedia "Tea in Turkey" (Rize as the main region since the mid-20th century), https://en.wikipedia.org/wiki/Tea_in_Turkey; en.wikipedia "Rize Province", https://en.wikipedia.org/wiki/Rize_Province ### Hazelnut orchards - Look for: multi-stemmed bushy trees 3–5 m high in irregular clumps on slopes, the ground underneath bare or grassy; nuts in green husks in late summer - Points to: the Black Sea region between Artvin and Kocaeli; most production is in Ordu, Giresun and Samsun, with Sakarya and Düzce in the west - Strength: weak (needs the month, and a second clue) - Counterexamples: hazel grows wild and in gardens elsewhere; from a distance the clumps look like any mixed scrub - Sources: tr.wikipedia "Türkiye'de fındık üretimi", https://tr.wikipedia.org/wiki/Türkiye'de_fındık_üretimi -
region.json 628 B
{ "code": "TR", "name": "Turkey", "names": ["Turkey", "Türkiye", "Turkiye", "Republic of Türkiye", "Türkiye Cumhuriyeti", "TR"], "status": "community", "maintainers": ["@mstkyvz"], "admin_levels": {"4": "admin1", "6": "admin2"}, "level_names": {"admin1": "il (province)", "admin2": "ilçe (district)"}, "name_suffixes": ["İli", "ili", "İlçesi", "ilçesi"], "admin_table": "data/admin.json", "lookups": { "plate": {"file": "data/plates.json"}, "area-code": {"file": "data/area_codes.json"} }, "docs": ["clues.md"], "services": { "poi": {"sources": "osm", "country": "tr"}, "street_view": "gsv.py" } } -
tests.json 1 KB
[ {"kind": "plate", "value": "34 ABC 123", "expect": [{"admin1": "İstanbul"}]}, {"kind": "plate", "value": "06", "expect": [{"admin1": "Ankara"}]}, {"kind": "plate", "value": "33 K 1234", "expect": [{"admin1": "Mersin"}]}, {"kind": "plate", "value": "23 AB 456", "expect": [{"admin1": "Elâzığ"}]}, {"kind": "plate", "value": "81 DA 99", "expect": [{"admin1": "Düzce"}]}, {"kind": "plate", "value": "34-03", "expect": [{"admin1": "İstanbul"}]}, {"kind": "plate", "value": "82 A 1234", "expect": []}, {"kind": "area-code", "value": "0212 555 12 34", "expect": [{"admin1": "İstanbul"}]}, {"kind": "area-code", "value": "(0216) 444 00 00", "expect": [{"admin1": "İstanbul"}]}, {"kind": "area-code", "value": "232 123 45 67", "expect": [{"admin1": "İzmir"}]}, {"kind": "area-code", "value": "0462", "expect": [{"admin1": "Trabzon"}]}, {"kind": "area-code", "value": "0392 228 00 00", "expect": []}, {"kind": "area-code", "value": "0850 222 00 00", "expect": []}, {"kind": "area-code", "value": "0532 111 22 33", "expect": []} ]
-
-
us
-
clues.md 628 B
# United States clues Entries use the shared format (`references/clues/README.md`). Cross-country clues that lead here (driving side, plate shape, script) are in `references/clues/global.md`. ## Clues ### US Interstate shields - Look for: the number on red-and-blue shield-shaped road signs - Points to: one specific Interstate; add a skyline landmark or an exit number to fix the segment and direction - Strength: strong (to one road) - Counterexamples: a highway with the same number runs through several states or areas; the sign is small and easy to miss (in v010-4 the human didn't see it, the AI did) - Sources: v010-4 -
region.json 204 B
{ "code": "US", "name": "United States", "names": [ "United States", "USA", "US", "United States of America", "美国" ], "status": "stub", "maintainers": [], "docs": [ "clues.md" ] }
-
-
_template
-
clues.md 581 B
# <Country> clues Entries use the shared format (`references/clues/README.md`). Only clues whose "Points to" stays inside this country belong here; clues that tell countries apart go into `references/clues/global.md`. ## <Topic, e.g. Text and plates> ### <Clue name> - Look for: exactly what to find in the image and how to recognize it - Points to: state / city / type of area - Strength: strong / medium / weak (a single source is "(single source)") - Counterexamples: when it leads to a wrong call - Sources: links to official or reference material, or the case it came from -
README.md 309 B
# Pack template Copy this folder to `regions/<cc>/` (ISO 3166-1 alpha-2, lower case), fill in `region.json` and `clues.md`, and delete this file. Lookup tables, an admin table and `tests.json` are optional (tier 2); their formats are in `regions/README.md`. Check with `uv run scripts/regions.py lint <cc>`. -
region.json 211 B
{ "code": "XX", "name": "Country name in English", "names": ["Country name in English", "Local name", "Common alias"], "status": "community", "maintainers": ["@your-github-handle"], "docs": ["clues.md"] }
-
-
README.md 6.5 KB
# Region packs Everything that only works inside one country lives here, one folder per country: `regions/<cc>/`, where `cc` is the ISO 3166-1 alpha-2 code in lower case (`cn`, `jp`, `in`). The core (SKILL.md, `references/`, `scripts/`) holds the methods and the cross-country clues, and reads packs through their manifest. Adding or removing a pack never changes the core. ## How the agent uses a pack 1. Country still open: cross-country clues (`references/clues/global.md`), calling codes, driving side. Lookups without `--country` search every pack and return matches from all countries that fit. 2. A country is a candidate: `regions.py show <cc>` prints the pack's card (lookups, preferred services, tips) and a one-line index of its clue entries with line numbers. Read only the entries you need. 3. Country settled: `board.py country <cc>`; from then on `board.py apply` uses only that pack, and `board.py next` uses its tips. 4. No pack for the country: global tools and references only. Lookups for it answer `unsupported`; they never fall back to another country's table. ## What a pack contains ``` regions/<cc>/ region.json manifest (required) clues.md clue entries (required; more docs can be listed in region.json "docs") data/*.json lookup tables and an admin table (optional) tests.json lookup cases (required when the pack has lookups) ``` **Packs hold data and documents only: `.json` and `.md`.** The skill runs on users' machines, and a pack is reviewed as data. If a table needs logic the formats below can't express, propose a change to the core format in a separate PR. ### region.json ```json { "code": "JP", "name": "Japan", "names": ["Japan", "日本", "Nippon"], "status": "community", "maintainers": ["@your-github-handle"], "docs": ["clues.md"], "admin_levels": {"4": "admin1", "7": "admin2"}, "level_names": {"admin1": "prefecture", "admin2": "municipality"}, "name_suffixes": [], "admin_table": "data/admin.json", "lookups": {"area-code": {"file": "data/area_codes.json"}}, "services": {"poi": {"sources": "osm", "country": "jp"}, "street_view": "gsv.py"}, "tips": {"livery": "…", "municipal": "…"} } ``` | Field | Meaning | |---|---| | `code`, `name`, `names` | ISO code, display name, every name or alias a user or table might use (`--country` matches any of them) | | `status` | `maintained` (core maintainers keep it current), `community` (a named maintainer does), `stub` (a few clues, no maintainer) | | `docs` | Markdown files the card indexes | | `admin_levels` | admin table `level` → board level: `admin1`, `admin2`, `city`, `district` | | `level_names` | what the levels are called locally, for the card | | `name_suffixes` | generic suffixes ignored when matching names across sources (CN: 市, 省, 区, 县) | | `admin_table` | optional; lets `board.py children` check OSM's list and gives candidates stable ids | | `lookups` | lookup kind → prefix table. Use an existing kind name when it means the same thing: `plate`, `area-code`, `postal-code` | | `services` | `poi` (sources and Nominatim country for `poi.py --region`), `street_view` (a core script), `datum` (local map datum, if any) | | `tips` | replaces the matching line of `board.py next`'s cheap tests (`livery`, `municipal`, …) | ### Prefix tables (`data/*.json` named in `lookups`) ```json { "_meta": { "kind": "area-code", "source": ["https://…"], "fetched": "2026-10-08", "license": "CC BY-SA 4.0 (derived from Wikipedia)", "count": 2, "normalize": {"digits_only": true, "ensure_prefix": "0"}, "key_pattern": null, "usage": "one line shown on the card: what to type", "miss_note": "what a miss usually means" }, "entries": { "020": [{"admin1": "Maharashtra", "admin2": "Pune"}], "0120": [{"admin1": "Uttar Pradesh", "admin2": "Ghaziabad"}, {"admin1": "Uttar Pradesh", "admin2": "Gautam Buddh Nagar", "note": "Noida"}] } } ``` - Lookup = normalize the value (`normalize`: `strip` characters, `digits_only`, `upper`, `ensure_prefix`), cut it with `key_pattern` if given (a value that doesn't fit is "not this country's format"), then take the **longest key that is a prefix** of it. - Each key maps to a **list**. A key that covers several places lists every one of them; never merge two places into one name, and never pick one. The board keeps them all as hypotheses. - A match names admin levels explicitly (`admin1` … `district`) with the names as the admin table or OSM spells them, plus optional `note` and `unverified: true` for rows not backed by the source. - A key that covers only part of an area (part of a state, part of a district) names the larger area and says what part in `note`. - `_meta.source`, `fetched` and `license` are required. Hand-compiled data says so in `source` and gives the references it was compiled from. ### Admin table (`admin_table`) `{"_meta": {source, fetched, license, count}, "items": [{"name", "code", "level", "parent"}]}` — `parent` is the parent's `name`; top-level items have `"parent": ""`. Every `level` needs an entry in `admin_levels`. ### clues.md Entries in the shared format (`references/clues/README.md`): Look for / Points to / Strength / Counterexamples / Sources, grouped under `##` topics. An entry belongs here when its "Points to" stays inside this country; clues that separate countries go into `references/clues/global.md`. ### tests.json ```json [{"kind": "area-code", "value": "0120-4567890", "expect": [{"admin1": "Uttar Pradesh", "admin2": "Ghaziabad"}, {"admin1": "Uttar Pradesh", "admin2": "Gautam Buddh Nagar"}]}] ``` At least one case per lookup kind; include a key that covers several places and a value that should miss (`"expect": []`). Only the level fields are compared. ## Contributing a pack | Tier | What | Review | |---|---|---| | 1 | Clue entries only (`region.json` + `clues.md`) | Entry format, sources, counterexamples | | 2 | Lookup tables (+ `tests.json`, optionally an admin table) | Tier 1 + data sources, licenses, multi-place keys, tests | | 3 | Anything that needs code: a new service adapter, OCR language support, a new table format | Open an issue first; core change in its own PR | A pack PR touches only `regions/<cc>/`. Start from `regions/_template/`, then run: ```bash uv run scripts/regions.py lint <cc> # manifest, sources, entry fields, no code files, tests uv run scripts/regions.py show <cc> # what the agent will see uv run scripts/clues.py lookup <kind> <value> --country <cc> ``` `lint` must print `ok`. It rejects code files, tables without source/date/license, keys that map to a merged name like "A / B" instead of a list, entries missing a field, and failing tests.
-
-
scripts
-
baidu_pano.py 17 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = ["pillow"] # /// """Baidu panoramas (street view in China): find points, scan an area, render by heading, build comparison sheets. Uses the public endpoints the Baidu Maps web page itself calls; no key needed. The endpoints are rate-limited: when scanning an area, keep the step at 15 m or more and concurrency at 32 or less. Baidu street view in China was mostly captured in 2017–2019; when comparing, look at old buildings, new ones may not be there yet. Examples: baidu_pano.py near 22.6047,114.0523 # nearest panorama point baidu_pano.py info 09005700121709201230455178V # date, position, all points on this road baidu_pano.py scan 22.6050,114.0535 --radius 300 --out panos.json baidu_pano.py render 09005700121709201230455178V --heading 47 --out v.jpg baidu_pano.py sheet --panos panos.json --toward 22.6072,114.0564 --offset -12 --out s.jpg baidu_pano.py sheet --ids ID1,ID2 --heading 45 --out s.jpg baidu_pano.py sheet --ids ID1 --headings 0,60,120,180,240,300 --out around.jpg # look around from a single point baidu_pano.py sheet --panos panos.json --road <name>路 --spread 60 --toward 22.6072,114.0564 --out s.jpg # one road only (路 = road), thinned baidu_pano.py sample --bbox 22.52,113.90,22.60,114.10 --n 24 --out cityA.jpg # street-view sample of a candidate city: compare guardrails, streetlights, bus stops """ from __future__ import annotations import argparse from _net import fetch_bytes, PROXY_HELP import io import json import math import os import re import sys from concurrent.futures import ThreadPoolExecutor from pathlib import Path from PIL import Image, ImageDraw, ImageFont sys.path.insert(0, str(Path(__file__).parent)) import geo # noqa: E402 API = "https://mapsv0.bdimg.com/" def _get(url: str, timeout: int = 20, proxy: str | None = None) -> bytes: return fetch_bytes(url, proxy, timeout) def near_bdmc(x: float, y: float, proxy: str | None = None) -> dict | None: try: c = json.loads(_get(f"{API}?qt=qsdata&x={x:.0f}&y={y:.0f}", proxy=proxy)).get("content") except Exception: return None if not c: return None bx, by = c["x"] / 100, c["y"] / 100 lat, lon = geo.convert(bx, by, "bdmc", "wgs") return {"id": c["id"], "bdmc": [bx, by], "wgs": [lat, lon], "road": c.get("RoadName", "")} def near(lat: float, lon: float, proxy: str | None = None) -> dict | None: return near_bdmc(*geo.convert(lat, lon, "wgs", "bdmc"), proxy=proxy) def info(pid: str, proxy: str | None = None) -> dict: c = json.loads(_get(f"{API}?qt=sdata&sid={pid}", proxy=proxy))["content"][0] x, y = c["X"] / 100, c["Y"] / 100 lat, lon = geo.convert(x, y, "bdmc", "wgs") roads = [] for r in c.get("Roads") or []: roads.append([{"id": p["PID"], "wgs": list(geo.convert(p["X"] / 100, p["Y"] / 100, "bdmc", "wgs"))} for p in (r.get("Panos") or [])]) # some points (construction access roads, isolated points) have no road-segment info return {"id": pid, "date": c.get("Date"), "wgs": [lat, lon], "bdmc": [x, y], "move_dir": c.get("MoveDir"), "roads": roads, "timeline": [t.get("ID") for t in (c.get("TimeLine") or [])]} def scan(lat: float, lon: float, radius_m: float, step_m: float, workers: int = 24, proxy: str | None = None) -> dict: """Query the nearest panorama point at each point of a square grid; deduplicate and return {id: {...}}.""" pts = [] n = int(radius_m // step_m) for i in range(-n, n + 1): for j in range(-n, n + 1): p = geo.dest(geo.dest((lat, lon), 0, i * step_m), 90, j * step_m) pts.append(p) found: dict = {} with ThreadPoolExecutor(workers) as ex: for r in ex.map(lambda p: near(*p, proxy=proxy), pts): if r: found[r["id"]] = r return found def thin(panos: dict, spread_m: float) -> dict: """Thin by minimum spacing: of points closer than spread_m, keep only the first.""" kept: dict = {} for k, v in panos.items(): if all(geo.distance(tuple(v["wgs"]), tuple(u["wgs"])) >= spread_m for u in kept.values()): kept[k] = v return kept def sample(bbox: tuple[float, float, float, float], n: int, spread_m: float, seed: int, skip: list[str], named_only: bool = True, workers: int = 16, proxy: str | None = None) -> list[dict]: """Scatter random points in the area to find panoramas, deduplicate, thin by spacing, take n; the heading is the capture car's direction of travel (along the road).""" import random rnd = random.Random(seed) s, w, nn, e = bbox found: dict = {} tries = 0 while len(found) < n and tries < n * 8: batch = [(rnd.uniform(s, nn), rnd.uniform(w, e)) for _ in range(n * 2)] tries += len(batch) with ThreadPoolExecutor(workers) as ex: for r in ex.map(lambda p: near(*p, proxy=proxy), batch): if not r or r["id"] in found or any(k in (r.get("road") or "") for k in skip): continue if named_only and not r.get("road"): # points without a road name are mostly internal roads of residential compounds and campuses, with no municipal fixtures in view continue if not (s <= r["wgs"][0] <= nn and w <= r["wgs"][1] <= e): continue if all(geo.distance(tuple(r["wgs"]), tuple(u["wgs"])) >= spread_m for u in found.values()): found[r["id"]] = r if len(found) >= n: break items = [] with ThreadPoolExecutor(8) as ex: infos = list(ex.map(lambda pid: info(pid, proxy=proxy), list(found)[:n])) for inf in infos: v = found[inf["id"]] items.append({"id": inf["id"], "heading": (inf.get("move_dir") or 0) % 360, "pitch": 0, "fov": 60, "date": inf.get("date"), "wgs": v["wgs"], "road": v.get("road", ""), "label": f"{(v.get('road') or '')[:10]} {str(inf.get('date') or '')[:6]}"}) return items def render(pid: str, heading: float, pitch: float = 10, fov: float = 80, w: int = 1024, h: int = 768, cache: Path | None = None, proxy: str | None = None) -> Image.Image: """Render a perspective view at a compass heading. heading 0 = north, positive pitch = looking up, fov is the vertical field of view. Max width 1024.""" w = min(w, 1024) key = f"{pid}_{heading:.0f}_{pitch:.0f}_{fov:.0f}_{w}x{h}.jpg" if cache: cache.mkdir(parents=True, exist_ok=True) if (cache / key).exists(): return Image.open(cache / key) url = (f"{API}?qt=pr3d&fovy={fov:.0f}&quality=80&panoid={pid}&heading={heading:.1f}" f"&pitch={pitch:.1f}&width={w}&height={h}") try: data = _get(url, 40, proxy) except Exception: return Image.new("RGB", (w, h), "gray") if cache: (cache / key).write_bytes(data) return Image.open(io.BytesIO(data)) # CJK-capable fonts, tried in order; GEO_FONT can point at any other font file. _FONT_PATHS = ( "C:/Windows/Fonts/msyh.ttc", "C:/Windows/Fonts/msyhbd.ttc", "C:/Windows/Fonts/simhei.ttf", "C:/Windows/Fonts/simsun.ttc", "/System/Library/Fonts/STHeiti Medium.ttc", "/System/Library/Fonts/Hiragino Sans GB.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", ) def _font(size: int): for p in (os.environ.get("GEO_FONT", "").strip(), *_FONT_PATHS): if not p: continue try: return ImageFont.truetype(p, size) except Exception: continue return ImageFont.load_default() def sheet(items: list[dict], out: Path, cols: int = 3, tw: int = 480, th: int = 360, cache: Path | None = None, proxy: str | None = None) -> None: """items: [{id, heading, pitch?, fov?, label?}] → numbered contact sheet.""" def one(it): return render(it["id"], it["heading"], it.get("pitch", 10), it.get("fov", 80), cache=cache, proxy=proxy) with ThreadPoolExecutor(12) as ex: ims = list(ex.map(one, items)) rows = (len(items) + cols - 1) // cols S = Image.new("RGB", (cols * tw, rows * th), "black") d = ImageDraw.Draw(S) f = _font(16) for i, (it, im) in enumerate(zip(items, ims)): x, y = (i % cols) * tw, (i // cols) * th S.paste(im.convert("RGB").resize((tw, th)), (x, y)) text = it.get("label") or f"{i}: …{it['id'][-9:]} h{it['heading']:.0f}" d.rectangle([x, y, x + tw, y + 22], fill="black") d.text((x + 4, y + 2), text, fill="yellow", font=f) S.save(out, quality=88) def _neg_coords(argv: list[str]) -> list[str]: """argparse treats negative coordinates like -1.45,-48.5 as option names; prefixing a space makes them plain values (float ignores the space). Needed for every puzzle in the southern or western hemisphere.""" return [" " + a if re.match(r"^-\d[\d.]*(,-?[\d.]+)+$", a) else a for a in argv] def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--proxy", help=PROXY_HELP) ap.add_argument("--cache", type=Path, default=Path(".geo-cache/pano")) sub = ap.add_subparsers(dest="cmd", required=True) n = sub.add_parser("near") n.add_argument("latlon") i = sub.add_parser("info") i.add_argument("id") s = sub.add_parser("scan") s.add_argument("center") s.add_argument("--radius", type=float, default=250) s.add_argument("--step", type=float, default=20) s.add_argument("--out", type=Path, required=True) r = sub.add_parser("render") r.add_argument("id") r.add_argument("--heading", type=float, required=True) r.add_argument("--pitch", type=float, default=10) r.add_argument("--fov", type=float, default=80) r.add_argument("--out", type=Path, required=True) sh = sub.add_parser("sheet") g = sh.add_mutually_exclusive_group(required=True) g.add_argument("--ids", help="comma-separated panoids") g.add_argument("--panos", type=Path, help="JSON output of scan") g.add_argument("--spec", type=Path, help="JSON list [{id, heading, pitch, fov, label}]") sh.add_argument("--heading", type=float, help="one heading for all") sh.add_argument("--headings", help="render these headings at every point, comma-separated, e.g. 0,60,120,180,240,300 (look around from a single point)") sh.add_argument("--toward", help="lat,lon: each point faces this target") sh.add_argument("--offset", type=float, default=0, help="angle added on top of the toward bearing") sh.add_argument("--pitch", type=float, default=10) sh.add_argument("--fov", type=float, default=80) sh.add_argument("--within", help="lat,lon,radius in meters: only points inside this circle") sh.add_argument("--road", help="only points whose road name contains these characters (the road field in scan output); comma-separated for several") sh.add_argument("--spread", type=float, help="thinning: minimum meters between neighboring points") sh.add_argument("--limit", type=int, default=12, help="max cells per contact sheet; extra cells go to more pages automatically") sh.add_argument("--out", type=Path, required=True) sp = sub.add_parser("sample", help="random street-view sample contact sheet of a candidate city/area: compare the styles of municipal fixtures such as guardrails, streetlights, bus stops, curbs") sp.add_argument("--bbox", required=True, help="south,west,north,east (urban area)") sp.add_argument("--n", type=int, default=24, help="how many points to sample") sp.add_argument("--spread", type=float, default=300, help="minimum spacing between two points, m") sp.add_argument("--seed", type=int, default=1) sp.add_argument("--skip", default="隧道,高速,高架,匝道,立交", help="skip points whose road name contains these characters, comma-separated (default: tunnel, expressway, elevated road, ramp, interchange)") sp.add_argument("--heading-offset", type=float, default=0, help="extra degrees to turn from the along-road direction (90 = look at the roadside)") sp.add_argument("--include-unnamed", action="store_true", help="also take points without a road name (internal roads of residential compounds, campuses); skipped by default") sp.add_argument("--out", type=Path, required=True) for parser in (n, i, s, r, sh, sp): parser.add_argument("--proxy", default=argparse.SUPPRESS, help=PROXY_HELP) args = ap.parse_args(_neg_coords(sys.argv[1:])) if args.cmd == "sample": bb = tuple(map(float, args.bbox.split(","))) items = sample(bb, args.n, args.spread, args.seed, [k for k in args.skip.split(",") if k], named_only=not args.include_unnamed, proxy=args.proxy) for it in items: it["heading"] = (it["heading"] + args.heading_offset) % 360 if not items: sys.exit("no panorama points found in the area (area too small, or no Baidu street view here)") pages = [items[k:k + 12] for k in range(0, len(items), 12)] for pi, page in enumerate(pages): out = args.out if pi == 0 else args.out.with_name(f"{args.out.stem}_{pi + 1}{args.out.suffix}") sheet(page, out, cache=args.cache, proxy=args.proxy) print(out) idx = args.out.with_suffix(".index.json") idx.write_text(json.dumps(items, ensure_ascii=False, indent=1), encoding="utf-8") dates = sorted(str(i.get("date") or "")[:4] for i in items) print(f"{len(items)} points, capture years {dates[0]}–{dates[-1]}; index -> {idx}") return if args.cmd == "near": print(json.dumps(near(*map(float, args.latlon.split(",")), proxy=args.proxy), ensure_ascii=False)) elif args.cmd == "info": print(json.dumps(info(args.id, proxy=args.proxy), ensure_ascii=False, indent=1)) elif args.cmd == "scan": lat, lon = map(float, args.center.split(",")) res = scan(lat, lon, args.radius, args.step, proxy=args.proxy) args.out.write_text(json.dumps(res, ensure_ascii=False, indent=1), encoding="utf-8") print(f"{len(res)} panos -> {args.out}") elif args.cmd == "render": render(args.id, args.heading, args.pitch, args.fov, cache=args.cache, proxy=args.proxy).save(args.out) print(args.out) elif args.cmd == "sheet": if args.spec: items = json.loads(args.spec.read_text(encoding="utf-8")) else: if args.ids: need_pos = bool(args.within or args.toward) panos = {p: (info(p, proxy=args.proxy) if need_pos else None) for p in args.ids.split(",")} else: panos = json.loads(args.panos.read_text(encoding="utf-8")) if args.within: wl, wo, wr = map(float, args.within.split(",")) panos = {k: v for k, v in panos.items() if geo.distance((wl, wo), v["wgs"]) <= wr} if args.road: keys = [k for k in args.road.split(",") if k] panos = {k: v for k, v in panos.items() if any(x in ((v or {}).get("road") or "") for x in keys)} print(f"{len(panos)} points after --road filter") if args.spread: panos = thin(panos, args.spread) print(f"{len(panos)} points after --spread thinning") target = tuple(map(float, args.toward.split(","))) if args.toward else None items = [] for pid, v in panos.items(): if args.headings: for hd in (float(x) for x in args.headings.split(",")): items.append({"id": pid, "heading": hd % 360, "pitch": args.pitch, "fov": args.fov}) continue if target: hd = geo.bearing(tuple(v["wgs"]), target) + args.offset elif args.heading is not None: hd = args.heading else: ap.error("need --heading, --headings or --toward") items.append({"id": pid, "heading": hd % 360, "pitch": args.pitch, "fov": args.fov}) pages = [items[k:k + args.limit] for k in range(0, len(items), args.limit)] or [[]] for pi, page in enumerate(pages): # page 1 is written to --out itself, later pages get _2, _3…, to avoid "the file given by --out doesn't exist" out = args.out if pi == 0 else args.out.with_name(f"{args.out.stem}_{pi + 1}{args.out.suffix}") sheet(page, out, cache=args.cache, proxy=args.proxy) print(out) if len(pages) > 1: print(f"{len(pages)} pages: {args.out.name} plus {args.out.stem}_2 … _{len(pages)}, {args.limit} cells per page") if len(pages) > 1 or args.spec is None: idx = args.out.with_suffix(".index.json") idx.write_text(json.dumps(items, indent=1), encoding="utf-8") print(f"index -> {idx}") if __name__ == "__main__": # Chinese-locale Windows writes GBK by default: m², ñ make it crash, and the Chinese the agent reads comes out garbled sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace") main() -
board.py 63.7 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = [] # /// """Candidate board: every candidate, clue, piece of evidence and exclusion during geolocation is recorded here; ranking and the next step are computed by the script, not from memory. Rules are written as code, not left to self-discipline: - There is no "delete candidate" operation. Exclusion has exactly one entry point, `exclude`, and it must be given a computed file (output of geo.py frame, terrain.py and the like); the clue itself must be text that was read or a computed result; an inference can only down-weight (likelihood ratio clamped to 1/3–3). - Exclusion scope must be ≤ evidence scope: for candidates with extent (district/area/road), exclude must state with --covers which stretch the evidence covers; coverage under half is rejected. Looking at one point and excluding the whole road (one point standing in for the whole area) is a mistake that recurred twice. - Discrete candidates are never averaged to a midpoint: the main answer in report is always first place; the rest go to alternatives. - Population and fame are not evidence: the prior is uniform by default (optionally by area); there is no by-population option. - Scan order is by "share ÷ pages": scan small districts first, put large districts last with a page cap. - When discriminating tests can't separate the candidates, next gives a definite next step instead of stalling. init create board.json add add candidates (country/province/city/district/area/point); --from bulk-imports output of poi.py / gazetteer.py / osm.py geom children use gazetteer.py to add every subdivision of an admin area as a candidate ("list the whole category first") clue record a clue: seen / text read / inferred / computed evidence a clue's likelihood ratio for some candidates (>1 supports, <1 opposes) exclude exclude a candidate (needs a computed file) scan-bbox set a candidate's scan extent (built-up area); pages are counted from it urban fill scan-bbox automatically with gazetteer.py urban falsify write the falsification condition before scanning/confirming rank current ranking (score, share, evidence, pages, share/page) next suggested next step check checklist before the conclusion report generate the candidates/alternatives/excluded/unused-clues fields of result.json apply lookup clues (calling code, driving side, territories; plates, area codes… from region packs) add candidates and evidence automatically (uses clues.py) move put a candidate under another candidate (a point under its road) so its supporting evidence counts there country set the country context: apply restricts region-pack lookups to it, next uses its pack's tips log print the ledger Examples: board.py init --photo photo.jpg board.py children <municipality or province name> # all 38 districts become candidates, uniform prior board.py add --from pois.json --level area --parent <city> # fine-level candidates (same-name campuses, OSM walled compounds) all go on the board; don't hand-pick board.py clue "bus yellow on top, green below; green rear stripe curves down" --kind livery --status observed --file bus_zoom.png board.py evidence --clue K1 --for <district A>:5 --for <district B>:2 --why "compared bus rears one by one across both districts' bus photos" --file livery_sheet.jpg board.py apply --kind plate --value <first two plate characters> board.py urban <district A> --within <municipality or province name> board.py rank board.py next board.py falsify <district A> --text "give up if the sports field's long axis isn't north-south" board.py exclude <district B> --clue K4 --computed frame.json --why "by the view-angle range X should be in frame and ≥40px; it isn't in the image" board.py check board.py report --merge result.json """ from __future__ import annotations import argparse from _net import PROXY_HELP import json import math import os import re import subprocess import sys from datetime import datetime from pathlib import Path import regions HERE = Path(__file__).parent # uv run writes its own path into the UV env var: call child scripts with it, so uv is found even when it isn't on PATH (e.g. just installed, terminal not reopened) UV = os.environ.get("UV") or "uv" LEVELS = ["country", "admin1", "admin2", "city", "district", "area", "road", "point"] LEVEL_LABEL = {"country": "country", "admin1": "province/state", "admin2": "prefecture/county", "city": "city", "district": "district", "area": "area", "road": "road", "point": "point"} # Candidates with extent: exclusion scope must be <= evidence scope. Looking at one point on a road/area and excluding the whole of it is a mistake that recurred twice EXTENDED_LEVELS = {"district", "area", "road"} COVERS_MIN = 0.5 # --covers must cover at least this fraction of the candidate's extent before the whole candidate can be excluded STATUS = ["observed", "read", "inferred", "computed"] # Likelihood ratio caps: inferences can only rank; only text read and computed results can move the score a lot LR_CAP = {"inferred": 3.0, "observed": 5.0, "read": 50.0, "computed": 50.0} GLOBAL_KINDS = ("calling-code", "driving-side", "territories") # clues.py tables in data/, not region packs CHEAP_OPS = [ ("plate/area-code/calling-code", "plate, area code or calling code readable → `clues.py lookup` + `board.py apply`"), ("terrain", "flat vs hill city in the image → `terrain.py view` or z13 satellite imagery per candidate to check terrain; one call rules out a batch"), ("livery", "bus/taxi livery → image search for \"<city> <color> bus\" in the local language; read route signs from the results, compare the rear waistline stripe district by district"), ("municipal", "guardrails, street lights, bus stops, curb styles → street view of each candidate's built-up area (`gsv.py sheet --points`, or the street-view script the region pack names), one contact sheet per candidate"), ("network", "water/road network template → `tiles.py fetch --zoom 13` per candidate, compare river direction and number of bridges"), ("phenology", "vegetation + month → weak evidence only, can't exclude on its own"), ] def _norm(s: str) -> str: return re.sub(r"[\s()()]", "", s or "").rstrip("市省区县自治州自治县自治区特别行政区") def _clue_kind(kind: str) -> str: # Accept the long spelling in new clues and in boards written by older versions. return "infra" if kind.strip().lower() in ("infra", "infrastructure") else kind def _load(p: Path) -> dict: if not p.exists(): sys.exit(f"No {p}: run `board.py init --photo photo.jpg` first") return json.loads(p.read_text(encoding="utf-8")) def _save(p: Path, b: dict) -> None: b["updated"] = datetime.now().isoformat(timespec="seconds") p.write_text(json.dumps(b, ensure_ascii=False, indent=1), encoding="utf-8") def _find(b: dict, name: str) -> str: if name in b["candidates"]: return name hits = [k for k in b["candidates"] if _norm(k) == _norm(name)] if len(hits) == 1: return hits[0] if not hits: sys.exit(f"No candidate \"{name}\": add it or use children first (existing: {', '.join(list(b['candidates'])[:12])}…)") sys.exit(f"\"{name}\" matches several candidates: {hits}; write the full name") def _log(b: dict, text: str) -> None: b.setdefault("log", []).append(f"{datetime.now():%H:%M} {text}") def _run(cmd: list[str]) -> subprocess.CompletedProcess: """Run gazetteer.py, clues.py. Child scripts and this one both use UTF-8: Chinese Windows reads and writes GBK by default, and if the two sides differ you get mojibake or crashes.""" return subprocess.run(cmd, text=True, encoding="utf-8", errors="replace", capture_output=True, env={**os.environ, "PYTHONUTF8": "1"}) def _bbox_km2(bb: list[float]) -> float: s, w, n, e = bb return abs(n - s) * 110.574 * abs(e - w) * 111.320 * math.cos(math.radians((s + n) / 2)) def _pages(bb: list[float], zoom: int, cell: int, cols: int) -> int: s, w, n, e = bb mpp = 156543.03392 * math.cos(math.radians((s + n) / 2)) / 2 ** zoom step = cell * mpp * 0.9 r = max(1, math.ceil((n - s) * 110574 / step)) c = max(1, math.ceil((e - w) * 111320 * math.cos(math.radians((s + n) / 2)) / step)) return math.ceil(r * c / (cols * cols)) def _inherited(b: dict, name: str, own_clues: set[str]) -> list[dict]: """Support flows up, refutation doesn't: a point that matches supports its road, a point that doesn't match says nothing about the rest of the road. Per clue the strongest supporting evidence among descendants, for clues the candidate has no evidence of its own.""" kids: dict[str, list[str]] = {} for n, c in b["candidates"].items(): if c.get("parent") and c.get("status") != "excluded": kids.setdefault(c["parent"], []).append(n) seen, todo, best = {name}, list(kids.get(name, [])), {} while todo: n = todo.pop() if n in seen: continue seen.add(n) todo += kids.get(n, []) for e in b["evidence"]: if e["candidate"] == n and e["lr"] > 1 and e["clue"] not in own_clues and e["lr"] > best.get(e["clue"], {}).get("lr", 1): best[e["clue"]] = {**e, "via": n} return list(best.values()) def _scores(b: dict, level: str, prior_by: str) -> list[dict]: rows = [] for name, c in b["candidates"].items(): if c["level"] != level: continue prior = 1.0 if prior_by == "area" and c.get("bbox"): prior = max(_bbox_km2(c["bbox"]), 1.0) logit = math.log(prior) + math.log(max(c.get("prior", 1.0), 1e-9)) ev = [e for e in b["evidence"] if e["candidate"] == name] ev += _inherited(b, name, {e["clue"] for e in ev}) for e in ev: logit += math.log(max(e["lr"], 1e-9)) rows.append({"name": name, "logit": logit, "n_ev": len(ev), "n_verified": sum(1 for e in ev if b["clues"][e["clue"]]["status"] in ("read", "computed")), "excluded": c.get("status") == "excluded", "c": c, "ev": ev}) open_rows = [r for r in rows if not r["excluded"]] if open_rows: m = max(r["logit"] for r in open_rows) z = sum(math.exp(r["logit"] - m) for r in open_rows) for r in open_rows: r["share"] = math.exp(r["logit"] - m) / z for r in rows: r.setdefault("share", 0.0) rows.sort(key=lambda r: (r["excluded"], -r["share"])) return rows def _frontier(b: dict) -> str | None: """The finest level that still has ≥2 non-excluded candidates; if none, the finest level that has candidates.""" for lv in reversed(LEVELS): if sum(1 for c in b["candidates"].values() if c["level"] == lv and c.get("status") != "excluded") >= 2: return lv for lv in reversed(LEVELS): if any(c["level"] == lv and c.get("status") != "excluded" for c in b["candidates"].values()): return lv return None def _cost_of(c: dict, args) -> tuple[int | None, str]: bb = c.get("scan_bbox") or c.get("bbox") if not bb: return None, "no extent" pages = _pages(bb, args.zoom, args.cell, args.cols) return pages, ("built-up area" if c.get("scan_bbox") else "whole-district bbox") # ---------------------------------------------------------------- subcommands def cmd_init(args, p: Path) -> None: if p.exists() and not args.force: sys.exit(f"{p} already exists (add --force to overwrite)") b = {"case": args.case or Path.cwd().name, "photo": args.photo, "created": datetime.now().isoformat(timespec="seconds"), "country": _country_code(args.country) if args.country else None, "candidates": {}, "clues": {}, "evidence": [], "falsify": {}, "log": []} _save(p, b) print(f"Created {p}" + (f" (country {b['country']})" if b["country"] else "")) def _country_code(text: str) -> str: pk = regions.resolve(text) if not pk: print(f"Note: no region pack for '{text}' (installed: {', '.join(sorted(regions.packs())) or 'none'}); " f"lookups that need a pack will answer unsupported, global tools still work.") return text.upper() return pk["code"] def cmd_country(args, p: Path) -> None: b = _load(p) if args.code in ("none", "-", ""): b["country"] = None _log(b, "country cleared") _save(p, b) print("Country context cleared: lookups search every region pack.") return b["country"] = _country_code(args.code) _log(b, f"country {b['country']}") _save(p, b) pk = regions.resolve(b["country"]) print(f"Country context {b['country']}: `apply` restricts region-pack lookups to it; `next` uses its tips." + (f" Card: `regions.py show {b['country'].lower()}`." if pk else "")) def _bbox_around(lat: float, lon: float, r: float) -> list[float]: dy, dx = r / 110574, r / (111320 * math.cos(math.radians(lat))) return [round(lat - dy, 6), round(lon - dx, 6), round(lat + dy, 6), round(lon + dx, 6)] def _coords(g) -> list[tuple[float, float]]: """All (lon, lat) in a GeoJSON geometry.""" if isinstance(g, (list, tuple)) and g and isinstance(g[0], (int, float)): return [(float(g[0]), float(g[1]))] out = [] for x in g or []: out += _coords(x) return out def _read_from(path: Path, radius: float) -> list[tuple[str, dict]]: """--from file → [(name, {bbox, center})]. Accepts four forms: poi.py/tiles.py {name: [lat, lon]}; gazetteer.py {name: {bbox, center}}; GeoJSON (osm.py geom, name taken from properties.name); [{name, lat, lon} or {name, bbox}]. Points get an extent from --radius.""" d = json.loads(path.read_text(encoding="utf-8")) rows: list[tuple[str, dict]] = [] def one(name: str, v) -> None: if isinstance(v, (list, tuple)) and len(v) >= 2 and all(isinstance(x, (int, float)) for x in v[:2]): lat, lon = float(v[0]), float(v[1]) rows.append((name, {"center": [lat, lon], "bbox": _bbox_around(lat, lon, radius)})) elif isinstance(v, dict): bb = v.get("bbox") c = v.get("center") or ([v["lat"], v["lon"]] if "lat" in v and "lon" in v else None) if bb: rows.append((name, {"center": c or [(bb[0] + bb[2]) / 2, (bb[1] + bb[3]) / 2], "bbox": bb})) elif c: rows.append((name, {"center": c, "bbox": _bbox_around(float(c[0]), float(c[1]), radius)})) if isinstance(d, dict) and d.get("type") == "FeatureCollection": for i, f in enumerate(d.get("features", []), 1): pr = f.get("properties") or {} pts = _coords((f.get("geometry") or {}).get("coordinates")) if not pts: continue lons, lats = [q[0] for q in pts], [q[1] for q in pts] name = pr.get("name") or f"{pr.get('@id') or pr.get('id') or f'#{i}'}" bb = [min(lats), min(lons), max(lats), max(lons)] if len(pts) == 1: bb = _bbox_around(lats[0], lons[0], radius) rows.append((name, {"center": [round((bb[0] + bb[2]) / 2, 6), round((bb[1] + bb[3]) / 2, 6)], "bbox": [round(v, 6) for v in bb]})) elif isinstance(d, dict): for name, v in d.items(): if not str(name).startswith("_"): one(str(name), v) elif isinstance(d, list): for i, v in enumerate(d, 1): if isinstance(v, dict): one(str(v.get("name") or f"#{i}"), v) # Same names (several campuses of one school, several same-name walled areas in OSM) get a sequence number; none are dropped seen: dict[str, int] = {} out = [] for name, v in rows: seen[name] = seen.get(name, 0) + 1 out.append((name if seen[name] == 1 else f"{name}#{seen[name]}", v)) return out def cmd_add(args, p: Path) -> None: b = _load(p) if args.level not in LEVELS: sys.exit(f"--level must be one of {LEVELS}") if not args.names and not args.from_: sys.exit("Give candidate names, or --from a file (poi.py --out, gazetteer.py --out, GeoJSON from osm.py geom)") if args.from_: rows = _read_from(Path(args.from_), args.radius) if not rows: sys.exit(f"No candidates with coordinates read from {args.from_}") n, added = 0, [] for name, v in rows: key = _slot(b, name, args.parent, None, args.level) if not key: continue added.append(key) b["candidates"][key] = {"level": args.level, "parent": args.parent, "bbox": v["bbox"], "scan_bbox": None, "center": v["center"], "prior": args.prior, "status": "open", "note": args.note or "", "from": str(args.from_)} n += 1 _log(b, f"add --from {args.from_} → +{n} {args.level}") _save(p, b) hints = [h for k in added for h in [_skip_hint(b, k)] if h] if hints: print(f"NOTE {hints[0]}" + (f" (and {len(hints) - 1} more)" if len(hints) > 1 else "")) print(f"Added {n} {LEVEL_LABEL[args.level]} candidates from {args.from_} ({len(rows)} read; duplicate names skipped). " f"Same names under another parent get the parent in the key. All on the board with a uniform prior: record evidence for every one you look at (record --against too when it doesn't match); check lists the ones not looked at.") for raw in args.names: name = _slot(b, raw, args.parent, None, args.level) if not name: print(f"Already have {raw}" + (f" under {args.parent}" if args.parent else "") + ", skipping") continue c = {"level": args.level, "parent": args.parent, "bbox": None, "scan_bbox": None, "prior": args.prior, "status": "open", "note": args.note or ""} if args.bbox: c["bbox"] = [float(v) for v in args.bbox.split(",")] b["candidates"][name] = c _log(b, f"add {name} ({args.level})") hint = _skip_hint(b, name) if hint: print(f"NOTE {hint}") _save(p, b) print(f"{len(b['candidates'])} candidates") def cmd_move(args, p: Path) -> None: b = _load(p) name = _find(b, args.name) parent = _find(b, args.parent) if parent == name: sys.exit("A candidate can't be its own parent") if LEVELS.index(b["candidates"][parent]["level"]) >= LEVELS.index(b["candidates"][name]["level"]): sys.exit(f"{parent} ({b['candidates'][parent]['level']}) isn't coarser than {name} ({b['candidates'][name]['level']})") old = b["candidates"][name].get("parent") b["candidates"][name]["parent"] = parent _log(b, f"move {name}: {old} → {parent}") _save(p, b) print(f"{name} now under {parent} (was {old}); its supporting evidence now counts for {parent} in the {b['candidates'][parent]['level']} ranking") hint = _skip_hint(b, name) if hint: print(f"NOTE {hint}") def cmd_children(args, p: Path) -> None: b = _load(p) cmd = [UV, "run", str(HERE / "gazetteer.py"), "children", args.parent, "--out", str(p.parent / ".gz_children.json")] if args.level: cmd += ["--level", str(args.level)] if args.within: cmd += ["--within", args.within] if args.proxy: cmd += ["--proxy", args.proxy] r = _run(cmd) for line in r.stderr.splitlines(): if "not treated as the next level" in line or "falling back to the first" in line: print(line) out = r.stdout.strip(); print(out if len(out) < 1800 else out[:1800].rsplit('\n', 1)[0] + '\n …') if r.returncode != 0: sys.exit(f"gazetteer failed: {r.stderr.strip()[-600:]}") kids = json.loads((p.parent / ".gz_children.json").read_text(encoding="utf-8")) as_level, why = _child_level(b, args, out) # Stable ids from a region pack's admin table, so lookups (plates, area codes) land on these same candidates pk, local = regions.admin_children(args.parent, b.get("country")) by_local = {} if pk: chain = regions.admin_chain(pk, regions.admin_find(args.parent, pk["code"])[0][1]) for it in local: by_local[_norm(it["name"])] = "/".join([pk["code"], *chain, it["name"]]) n = 0 for raw, k in kids.items(): cid = by_local.get(_norm(raw)) name = _slot(b, raw, args.parent, cid, as_level) if not name: continue b["candidates"][name] = {"level": as_level, "parent": args.parent, "bbox": k.get("bbox"), "scan_bbox": None, "prior": 1.0, "status": "open", "note": k.get("note", ""), "osm_id": k.get("osm_id"), "country": pk["code"] if pk else b.get("country"), "cid": cid} n += 1 _log(b, f"children {args.parent} → +{n} {as_level}") _save(p, b) print(f"Added {n} candidates (level {as_level}: {why}; uniform prior). Population and fame don't enter the score.") # Parent's level on the board → level the children are recorded as NEXT_LEVEL = {"country": "admin1", "admin1": "admin2", "admin2": "district", "city": "district", "district": "area", "area": "road", "road": "point"} def _child_level(b: dict, args, gz_out: str) -> tuple[str, str]: """Candidate level for children: use --as-level if given; parent is a country → admin1; parent already on the board → its next level (where a province's next level down is directly districts, as with a municipality, record as district); otherwise → district.""" if args.as_level: return args.as_level, "set by --as-level" m = re.search(r"admin_level (\d+)\) children admin_level (\d+)", gz_out) p_lv, c_lv = (int(m.group(1)), int(m.group(2))) if m else (None, None) if p_lv is not None and p_lv <= 2: # When jumping straight from a country to a finer level (--level 5/6), don't record as province level lv = "admin1" if c_lv is None or c_lv <= 4 else ("admin2" if c_lv == 5 else "district") return lv, f"parent is a country, children admin_level {c_lv}" hits = [k for k in b["candidates"] if _norm(k) == _norm(args.parent)] if len(hits) == 1: lv = b["candidates"][hits[0]]["level"] nxt = NEXT_LEVEL.get(lv, "district") if lv == "admin1" and p_lv is not None and c_lv is not None and c_lv >= p_lv + 2: nxt = "district" return nxt, f"parent {hits[0]} is {lv} on the candidate board" return "district", "default" def cmd_clue(args, p: Path) -> None: b = _load(p) kid = f"K{len(b['clues']) + 1}" if args.status not in STATUS: sys.exit(f"--status must be one of {STATUS}: observed=shape/color seen directly in the image, read=text/numbers read, inferred=inference (building about 8 floors, road going uphill), computed=computed by a script") resolves = list(dict.fromkeys(getattr(args, "resolves", None) or [])) output = args.file or "" if resolves: if args.status != "computed": sys.exit("--resolves requires --status computed: link a result that actually tests the named clues") unknown = [k for k in resolves if k not in b["clues"]] if unknown: sys.exit(f"Unknown clues in --resolves: {', '.join(unknown)}") if not output or not Path(output).is_file(): sys.exit("--resolves requires --file pointing to an existing computed output file") output = str(Path(output).resolve()) kind = _clue_kind(args.kind) b["clues"][kid] = {"text": args.text, "kind": kind, "status": args.status, "file": output, "source": args.source or "", "used": False} if resolves: b["clues"][kid]["resolves"] = resolves _log(b, f"clue {kid} [{kind}/{args.status}] {args.text}" + (f"; resolves {', '.join(resolves)}" if resolves else "")) _save(p, b) cap = LR_CAP[args.status] print(f"{kid} recorded. Status {args.status}: likelihood ratio cap {cap:g}" + (", can only rank, can't exclude" if args.status in ("inferred", "observed") else ", usable for exclude (still needs a computed file)")) def _parse_lr(items: list[str] | None, default: float | None) -> list[tuple[str, float]]: out = [] for it in items or []: if ":" in it: name, v = it.rsplit(":", 1) out.append((name, float(v))) elif default is not None: out.append((it, default)) else: sys.exit(f"Write it as name:likelihood_ratio, e.g. {it}:5") return out def cmd_evidence(args, p: Path) -> None: b = _load(p) if args.clue not in b["clues"]: sys.exit(f"No clue {args.clue}; run `board.py clue` first") cl = b["clues"][args.clue] cap = LR_CAP[cl["status"]] pairs = _parse_lr(args.for_, 3.0) + [(n, v) for n, v in _parse_lr(args.against, 1 / 3)] if not pairs: sys.exit("Give at least one --for name:likelihood_ratio or --against name:likelihood_ratio") clipped = [] for name, lr in pairs: cname = _find(b, name) if lr <= 0: sys.exit("A likelihood ratio of 0 means exclusion; use `board.py exclude` (needs a computed file)") lr2 = min(max(lr, 1 / cap), cap) if abs(lr2 - lr) > 1e-9: clipped.append(f"{cname}:{lr:g}→{lr2:g}") b["evidence"].append({"id": f"E{len(b['evidence']) + 1}", "clue": args.clue, "candidate": cname, "lr": lr2, "why": args.why or "", "file": args.file or "", "cmd": args.command_ or ""}) cl["used"] = True if args.file and not Path(args.file).exists(): print(f"Note: {args.file} doesn't exist; evidence files must be real outputs of this session") _log(b, f"evidence {args.clue} → {', '.join(f'{n}:{v:g}' for n, v in pairs)}") _save(p, b) if clipped: print(f"Clue {args.clue} is \"{cl['status']}\", likelihood ratio clamped to 1/{cap:g}–{cap:g}: {'; '.join(clipped)}. " f"For more weight, first verify the clue as read/computed (read the text, run a script), then record a new clue.") print("Recorded." + ("" if args.why else " Suggest adding --why to state the basis.")) def cmd_exclude(args, p: Path) -> None: b = _load(p) cname = _find(b, args.name) if args.clue not in b["clues"]: sys.exit(f"No clue {args.clue}") cl = b["clues"][args.clue] if cl["status"] not in ("read", "computed"): sys.exit(f"Clue {args.clue} is \"{cl['status']}\" (inferred/observed): it can't exclude, only `evidence --against {cname}:0.34`. " f"Hard rule 9: exclusion and confirmation use the same standard.") if not args.computed or not Path(args.computed).exists(): sys.exit("Exclusion must attach a computed file (--computed, e.g. geo.py frame output or a terrain.py comparison image), and the file must actually exist") c = b["candidates"][cname] if c["level"] in EXTENDED_LEVELS: if not args.covers: sys.exit( f"{cname} is a {LEVEL_LABEL[c['level']]}-level candidate (has extent): exclusion needs --covers stating where the evidence actually covers " f"('lat,lon' or 'lat,lon:lat,lon'). Hard rule 9: exclusion scope must be ≤ evidence scope — " f"looking at one point and excluding the whole thing is one point standing in for the whole area. If you only checked one stretch, use instead: " f"board.py evidence --clue {args.clue} --against {cname}:0.34 --file {args.computed}") pts = _parse_covers(args.covers) if not pts: sys.exit("--covers must parse to coordinates: 'lat,lon' (single point) or 'lat,lon:lat,lon' (span)") r = _covers_ratio(c, pts) if r is not None and r[0] < COVERS_MIN: cov_m = _cov_span_m(pts[0], pts[-1]) if len(pts) >= 2 else 0.0 sys.exit( f"--covers covers only about {r[0]:.0%} of {cname} (evidence {cov_m:.0f} m / candidate extent diagonal {r[1]:.0f} m): " f"not enough to exclude the whole thing. Add comparison images for the remaining stretches before excluding, or down-weight first: " f"board.py evidence --clue {args.clue} --against {cname}:0.34 --file {args.computed}") c["status"] = "excluded" c["excluded_by"] = {"clue": args.clue, "computed": args.computed, "why": args.why or "", "covers": args.covers or ""} cl["used"] = True _log(b, f"exclude {cname} by {args.clue} ({args.computed})") _save(p, b) print(f"Excluded {cname}. Excluded candidates stay in the ledger; check looks back at them.") def cmd_scan_bbox(args, p: Path) -> None: b = _load(p) cname = _find(b, args.name) b["candidates"][cname]["scan_bbox"] = [float(v) for v in args.bbox.split(",")] _log(b, f"scan-bbox {cname} {args.bbox}") _save(p, b) print("ok") def cmd_urban(args, p: Path) -> None: b = _load(p) cname = _find(b, args.name) cmd = [UV, "run", str(HERE / "gazetteer.py"), "urban", cname] if args.within: cmd += ["--within", args.within] if args.proxy: cmd += ["--proxy", args.proxy] r = _run(cmd) if r.returncode != 0: sys.exit(f"gazetteer urban failed: {r.stderr.strip()[-600:]}") d = json.loads(r.stdout[r.stdout.index("{"):]) if d.get("urban_bbox"): b["candidates"][cname]["scan_bbox"] = d["urban_bbox"] b["candidates"][cname]["urban_source"] = d.get("source") _log(b, f"urban {cname} {d['urban_bbox']} ({d.get('source')})") _save(p, b) print(f"{cname} built-up area {d['urban_bbox']}, about {d.get('urban_bbox_km2')} km² (source {d.get('source')})" + (f"; {d['note']}" if d.get("note") else "")) else: print(f"{cname}: {d.get('note', 'no built-up area data')}; set it by hand with `board.py scan-bbox`") def cmd_falsify(args, p: Path) -> None: b = _load(p) cname = _find(b, args.name) b["falsify"].setdefault(cname, []).append(args.text) _log(b, f"falsify {cname}: {args.text}") _save(p, b) print("Falsification condition recorded. If it shows up, give up; don't look for reasons to explain it away.") def cmd_rank(args, p: Path, quiet: bool = False) -> dict: b = _load(p) out = {} for lv in LEVELS: rows = _scores(b, lv, args.prior_by) if not rows: continue out[lv] = rows if quiet: continue print(f"\n[{LEVEL_LABEL[lv]}] {len(rows)} candidates ({sum(1 for r in rows if not r['excluded'])} not excluded)") print(f" {'candidate':<14}{'share':>7}{'evidence':>9}{'verified':>9}{'pages':>6} {'share/page':>10} note") for r in rows[: args.limit]: pages, src = _cost_of(r["c"], args) ratio = (r["share"] / pages) if pages else None flag = "excluded" if r["excluded"] else "" print(f" {r['name']:<14}{r['share']:>7.1%}{r['n_ev']:>9}{r['n_verified']:>9}{(pages if pages is not None else '?'):>6}" f" {(f'{ratio:.4f}' if ratio is not None else '?'):>10} {flag} {src if pages is not None else ''} {r['c'].get('note', '')[:30]}") if len(rows) > args.limit: print(f" … {len(rows) - args.limit} more (raise --limit)") if not out and not quiet: print("No candidates: run `board.py add` or `board.py children` first") return out def _corridor_pending(b: dict) -> list[str]: """Infrastructure observations without a computed result explicitly addressing them.""" infra = [k for k, c in b["clues"].items() if _clue_kind(c["kind"]) == "infra" and c["status"] in ("observed", "read")] done = {k for c in b["clues"].values() if c["status"] == "computed" and c.get("resolves") and c.get("file") and Path(c["file"]).is_file() for k in c.get("resolves", [])} return [k for k in infra if k not in done] def cmd_next(args, p: Path) -> None: b = _load(p) pend = _corridor_pending(b) if pend: print(f"→ First: infrastructure clue {', '.join(pend)} has no linked corridor result yet. `osm.py geom '<filter>' --bbox <region> --out lines.geojson` → " f"`terrain.py scan --lines lines.geojson` (mountains fill the frame: add `--flat-run 0 --min-low-deg 0`) → `terrain.py fit`; " f"two or three kinds of infrastructure: `osm.py near`. Record a result that actually tests the clue with " f"`board.py clue '<result>' --kind corridor --status computed --file <output> --resolves <clue-id>` " f"(repeat --resolves for each clue tested).") lv = _frontier(b) if not lv: print("No candidates. First do step 2 (lookup clues) or step 4 (coarse location from the environment) and list the candidates in full (board.py children)") return rows = [r for r in _scores(b, lv, args.prior_by) if not r["excluded"]] top = rows[0] second = rows[1] if len(rows) > 1 else None unused = [k for k, c in b["clues"].items() if not c["used"]] print(f"Current frontier: {LEVEL_LABEL[lv]}, {len(rows)} not excluded; first {top['name']} {top['share']:.0%}" + (f", second {second['name']} {second['share']:.0%}" if second else "")) if unused: pending = ", ".join(f"{k}({b['clues'][k]['kind']})" for k in unused) print(f"Clues not used yet: {pending} → evidence or apply first") separable = (not second) or (top["share"] >= 0.7 and top["share"] / max(second["share"], 1e-9) >= 3) if separable and top["n_verified"] == 0 and second: print(f"{top['name']} leads but has no verified evidence at all (read/computed); it's built from inferences only: do one cheap verification before scanning.") if separable: pages, src = _cost_of(top["c"], args) print(f"→ Ready to narrow/scan: {top['name']} ({src}, about {pages if pages is not None else '?'} pages).") if src == "whole-district bbox": print(" First `board.py urban <name>` or `scan-bbox` to shrink the extent to the built-up area; pages drop by an order of magnitude.") elif pages is None: print(f" It has no extent yet: `board.py urban {top['name']} --within <parent>` or `board.py scan-bbox {top['name']} --bbox s,w,n,e`, otherwise pages can't be computed.") if top["name"] not in b["falsify"]: print(f" Write the falsification condition before scanning: `board.py falsify {top['name']} --text \"…\"`") print(" Let the machine rank the scan first: `sat_scan.py grid --bbox <scan_bbox> --preset …`, `osm.py buildings`, `poi.py`; look only at the top 20–30.") return print("→ Can't separate. Do discriminating tests from cheap to expensive, each on all non-excluded candidates together, not just the leader:") kinds_have = {c["kind"] for c in b["clues"].values()} pk = _region_of(b, rows) tips = (pk or {}).get("tips") or {} if pk: print(f" (region pack {pk['code']}: `regions.py show {pk['code'].lower()}` lists its lookups, services and clue index)") for kind, tip in [(k, tips.get(k, t)) for k, t in CHEAP_OPS]: mark = "(you already have a clue of this kind; record evidence)" if any(k in kinds_have for k in kind.split("/")) else "" print(f" - {kind}: {tip} {mark}") print("→ All cheap tests done and still can't separate: don't stop. Scan in \"share ÷ pages\" order, finish the small ones first, set a page cap on the large ones:") order = [] for r in rows: pages, src = _cost_of(r["c"], args) order.append((r["share"] / pages if pages else 0, r, pages, src)) order.sort(key=lambda t: -t[0]) for ratio, r, pages, src in order[:8]: print(f" {r['name']:<14} share {r['share']:.0%} pages {pages if pages is not None else '?'} ({src}) share/page {ratio:.4f}") if any(pages is None for _, _, pages, _ in order): print(" Some candidates have no extent: fill it in with `board.py urban` or `scan-bbox`, otherwise they can't be ordered") def _region_of(b: dict, rows: list[dict]) -> dict | None: """The board's country, else the one country all open frontier candidates share.""" if b.get("country"): return regions.resolve(b["country"]) codes = {r["c"].get("country") for r in rows} return regions.resolve(codes.pop()) if len(codes) == 1 and None not in codes else None def _parse_covers(s: str) -> list[tuple[float, float]]: """--covers: 'lat,lon' or 'lat,lon:lat,lon' (the point/span the evidence actually covers). Returns [] if no coordinates parse.""" pts = [] for part in str(s).split(":"): m = re.findall(r"-?\d+\.\d+|-?\d+", part) if len(m) >= 2: pts.append((float(m[0]), float(m[1]))) return pts def _cov_span_m(a: tuple[float, float], b: tuple[float, float]) -> float: kx = 111320.0 * math.cos(math.radians((a[0] + b[0]) / 2)) return math.hypot((b[1] - a[1]) * kx, (b[0] - a[0]) * 110540.0) def _covers_ratio(c: dict, pts: list[tuple[float, float]]) -> tuple[float, float] | None: """(evidence coverage length / candidate extent diagonal, diagonal in meters). None if the candidate has no bbox (can't compute, only warn).""" bb = c.get("bbox") or c.get("scan_bbox") if not bb or len(bb) != 4: return None try: sw, ww, nn, ee = (float(v) for v in bb) except (TypeError, ValueError): return None diag = _cov_span_m((sw, ww), (nn, ee)) if diag <= 1.0: return None cov = _cov_span_m(pts[0], pts[-1]) if len(pts) >= 2 else 0.0 return cov / diag, diag def _unseen(rows: list[dict], lv: str) -> list[str]: """Non-excluded candidates at fine levels (area/road/point) without a single piece of evidence. Coarse levels are scanned in share/pages order, where no evidence is normal, so they don't count.""" if lv not in ("area", "road", "point"): return [] return [r["name"] for r in rows if r["n_ev"] == 0 and not r["excluded"]] def _center_of(c: dict) -> tuple[float, float] | None: if c.get("center"): return float(c["center"][0]), float(c["center"][1]) bb = c.get("bbox") return ((bb[0] + bb[2]) / 2, (bb[1] + bb[3]) / 2) if bb else None def _inside(c: dict, lat: float, lon: float, pad_m: float = 30) -> bool | None: """Is (lat, lon) inside the candidate's bbox (padded)? None when the candidate has no bbox.""" bb = c.get("bbox") if not bb: return None dy, dx = pad_m / 110574, pad_m / (111320 * math.cos(math.radians(lat))) return bb[0] - dy <= lat <= bb[2] + dy and bb[1] - dx <= lon <= bb[3] + dx def _skipped_level(b: dict, name: str) -> tuple[str, list[str], str | None] | None: """A candidate that skips a level its siblings use: a point added under a city that also has road candidates. Its evidence then never reaches that level's ranking, which goes on ranking the other roads. Returns (skipped level, the siblings at that level, the one whose bbox contains it or None).""" c = b["candidates"][name] li = LEVELS.index(c["level"]) for lv in reversed(LEVELS[:li]): sibs = [n for n, s in b["candidates"].items() if s["level"] == lv and s.get("parent") == c.get("parent") and n != c.get("parent") and s.get("status") != "excluded"] if not sibs: continue at = _center_of(c) home = next((n for n in sibs if at and _inside(b["candidates"][n], *at)), None) return lv, sibs, home return None def _skip_hint(b: dict, name: str) -> str | None: gap = _skipped_level(b, name) if not gap: return None lv, sibs, home = gap c = b["candidates"][name] where = (f"it lies inside {home} → `board.py move '{name}' --parent '{home}'`" if home else f"none of them contains it, so that ranking leaves the answer out → `board.py add <its {lv}> --level {lv} --parent '{c.get('parent')}' --bbox s,w,n,e`, " f"then `board.py move '{name}' --parent <it>`") return (f"{name} ({c['level']}) sits directly under {c.get('parent')}, which also has {len(sibs)} {lv} candidates; " f"its evidence never reaches the {lv} ranking: {where}") def cmd_check(args, p: Path) -> None: b = _load(p) ok = True print("Pre-conclusion check:") for name, c in b["candidates"].items(): if c.get("status") == "excluded": ex = c.get("excluded_by", {}) if not ex.get("computed") or not Path(ex["computed"]).exists(): ok = False print(f" FAIL file for excluding {name} doesn't exist: {ex.get('computed')}") for name, c in b["candidates"].items(): if c.get("status") != "excluded" or c["level"] not in EXTENDED_LEVELS: continue ex = c.get("excluded_by", {}) pts = _parse_covers(ex.get("covers", "")) if not pts: ok = False print(f" FAIL excluding {name} ({LEVEL_LABEL[c['level']]} level) has no --covers recorded: no way to judge how much the evidence covered, " f"possibly one point standing in for the whole area → re-run exclude with --covers, or switch to evidence --against to down-weight") continue r = _covers_ratio(c, pts) if r is None: print(f" NOTE evidence for excluding {name} covers {ex['covers']}; the candidate has no bbox, so the coverage ratio can't be computed → " f"state in the conclusion that only this stretch was checked") elif r[0] < COVERS_MIN: ok = False print(f" FAIL evidence for excluding {name} covers only about {r[0]:.0%} (candidate extent diagonal {r[1]:.0f} m): exclusion scope exceeds evidence scope") for name, c in b["candidates"].items(): if c.get("status") != "excluded" and any(e["candidate"] == name and e["lr"] > 1 for e in b["evidence"]): hint = _skip_hint(b, name) if hint: ok = False print(f" FAIL {hint}") lv = _frontier(b) if lv: rows = [r for r in _scores(b, lv, args.prior_by) if not r["excluded"]] top = rows[0] if top["n_verified"] == 0: ok = False print(f" FAIL main answer {top['name']} has no read/computed evidence, only observations and inferences: you can't claim this level or finer") else: print(f" ok main answer {top['name']}: {top['n_ev']} pieces of evidence, {top['n_verified']} verified") weak = [r["name"] for r in rows[1:] if r["share"] >= 0.15] if weak: print(f" WARN alternatives with share ≥15% remain: {', '.join(weak)} → write them into alternatives with a discriminating test; no midpoint") unseen = _unseen(rows, lv) if unseen: print(f" WARN {len(unseen)}/{len(rows)} {LEVEL_LABEL[lv]} candidates have no evidence at all, i.e. were never looked at: " f"{', '.join(unseen[:10])}{' …' if len(unseen) > 10 else ''} → look at each; record evidence --against even when it doesn't match; " f"unseen ones don't count as excluded; state this in the conclusion") down = [(e["candidate"], e["clue"]) for e in b["evidence"] if e["lr"] < 1 and b["clues"][e["clue"]]["status"] in ("inferred", "observed") and b["candidates"][e["candidate"]].get("status") != "excluded"] if down: names = sorted({n for n, _ in down}) print(f" NOTE candidates down-weighted by inference but not excluded: {', '.join(names[:10])} → when no candidate matches, look back at these first") for k, c in b["clues"].items(): if c["status"] in ("read", "computed") and not c.get("file"): print(f" WARN {k} is {c['status']} but has no file: text read needs a zoomed image, computed results need an output file") # fine-level answer needs the evidence image the Output section asks for fine = [n for n, c in b["candidates"].items() if c["level"] in ("area", "road", "point") and c.get("status") != "excluded" and any(e["candidate"] == n and e["lr"] > 1 for e in b["evidence"])] if fine: ev = Path(args.evidence) if args.evidence else p.parent / "evidence.jpg" if not ev.exists(): ok = False print(f" FAIL evidence image {ev} doesn't exist: supporting evidence reaches area/road/point level ({', '.join(fine[:3])}) → make it with evidence.py " f"(camera + heading wedge on satellite, comparison panels), or pass --evidence <path>") else: print(f" ok evidence image {ev}") unused = [k for k, c in b["clues"].items() if not c["used"]] if unused: print(f" NOTE unused clues: {', '.join(unused)} → write them into unused_clues") for name in [n for n, c in b["candidates"].items() if c.get("status") != "excluded"]: if b["falsify"].get(name): print(f" ok {name} falsification condition: {'; '.join(b['falsify'][name])}") print("Result: " + ("pass" if ok else "has FAIL, fix those first")) def cmd_report(args, p: Path) -> None: b = _load(p) lv = _frontier(b) rep = {"candidate_levels": {}, "main": None, "alternatives": [], "excluded": [], "unused_clues": [], "downweighted_not_excluded": [], "falsify": b["falsify"]} for l2 in LEVELS: rows = _scores(b, l2, args.prior_by) if rows: rep["candidate_levels"][l2] = [{"name": r["name"], "share": round(r["share"], 3), "evidence": r["n_ev"], "verified": r["n_verified"], "excluded": r["excluded"]} for r in rows] if lv: rows = [r for r in _scores(b, lv, args.prior_by) if not r["excluded"]] top = rows[0] rep["main"] = {"level": lv, "name": top["name"], "share": round(top["share"], 3), "evidence": [{"clue": b["clues"][e["clue"]]["text"], "status": b["clues"][e["clue"]]["status"], "lr": e["lr"], "why": e["why"], "file": e["file"], **({"via": e["via"]} if e.get("via") else {})} for e in top["ev"]]} for r in rows[1:]: if r["share"] >= 0.05: rep["alternatives"].append({"name": r["name"], "share": round(r["share"], 3), "how_to_separate": "run one cheap test on both together (terrain/livery/municipal fixtures/water template), or scan the first 3 pages of each built-up area"}) rep["unexamined"] = _unseen(rows, lv) rep["level_gaps"] = [h for n, c in b["candidates"].items() if c.get("status") != "excluded" and any(e["candidate"] == n and e["lr"] > 1 for e in b["evidence"]) for h in [_skip_hint(b, n)] if h] for name, c in b["candidates"].items(): if c.get("status") == "excluded": ex = c.get("excluded_by", {}) rep["excluded"].append({"name": name, "clue": b["clues"].get(ex.get("clue"), {}).get("text", ""), "computed": ex.get("computed"), "why": ex.get("why", "")}) rep["unused_clues"] = [c["text"] for c in b["clues"].values() if not c["used"]] rep["downweighted_not_excluded"] = sorted({e["candidate"] for e in b["evidence"] if e["lr"] < 1 and b["clues"][e["clue"]]["status"] in ("inferred", "observed") and b["candidates"][e["candidate"]].get("status") != "excluded"}) if args.merge: mp = Path(args.merge) base = json.loads(mp.read_text(encoding="utf-8")) if mp.exists() else {} if rep["main"] and isinstance(base.get("lat"), (int, float)) and isinstance(base.get("lon"), (int, float)): inside = _inside(b["candidates"][rep["main"]["name"]], base["lat"], base["lon"], pad_m=max(30, float(base.get("radius_m") or 0))) if inside is False: rep["main_mismatch"] = (f"result.json lat/lon is outside the board's main answer {rep['main']['name']} ({rep['main']['level']}): " f"the answer's candidate isn't on that level, or the board ranking disagrees with the conclusion") base["board"] = rep if rep["alternatives"]: base.setdefault("alternatives", []) base["alternatives"] = [f"{a['name']} (share {a['share']:.0%}): {a['how_to_separate']}" for a in rep["alternatives"]] + \ [x for x in base.get("alternatives", []) if not isinstance(x, str) or "(share " not in x] base["excluded"] = [f"{x['name']}: {x['why']} ({x['computed']})" for x in rep["excluded"]] or base.get("excluded", []) base["unused_clues"] = rep["unused_clues"] or base.get("unused_clues", []) mp.write_text(json.dumps(base, ensure_ascii=False, indent=1), encoding="utf-8") print(f"Merged into {mp} (board field + alternatives/excluded/unused_clues)") print(json.dumps(rep, ensure_ascii=False, indent=1)[:4000]) for w in rep["level_gaps"] + ([rep["main_mismatch"]] if rep.get("main_mismatch") else []): print(f"WARN {w}") if rep["main"] and rep["alternatives"]: print("\nReminder: main answer = first place, alternatives listed separately; no midpoint, no big circle.") SUB_LEVELS = {"admin2", "city", "district"} # children may record a prefecture as district; these count as one tier for name matching def _same_tier(a: str, b: str) -> bool: """Levels at which two same-named places could be one place (吉林省 and its 吉林市 share a stem, not a level).""" return a == b or (a in SUB_LEVELS and b in SUB_LEVELS) def _slot(b: dict, name: str, parent: str | None, cid: str | None, level: str) -> str | None: """Key for a new candidate, or None when the board already has this place. A same-name place under another parent (Gulou District in Nanjing and in Fuzhou) is a different candidate and gets its parent in the key.""" if name in b["candidates"] and not _same_tier(b["candidates"][name]["level"], level): return f"{name} ({level})" if f"{name} ({level})" not in b["candidates"] else None same = [k for k in b["candidates"] if _norm(k.split(" (")[0]) == _norm(name) and _same_tier(b["candidates"][k]["level"], level)] if not same: return name for k in same: c = b["candidates"][k] if cid and c.get("cid"): if c["cid"] == cid: return None continue if not parent or not c.get("parent") or _norm(c["parent"]) == _norm(parent): return None key = f"{name} ({parent})" return None if key in b["candidates"] else key def _same_place(c: dict, name: str, parent: str | None, code: str | None, cid: str | None) -> bool: """Is board candidate c the place a lookup names? Same stable id; or same name with no conflicting id, country or parent.""" if cid and c.get("cid"): return c["cid"] == cid if code and c.get("country") and c["country"] != code: return False if parent and c.get("parent") and _norm(c["parent"]) != _norm(parent): return False return True def _ensure(b: dict, level: str, name: str, parent: str | None, code: str | None, cid: str | None, why: str) -> str: """Candidate key for this place, creating it if needed. Same-name places elsewhere get their parent in the key, e.g. 朝阳区 (长春市).""" for k, c in b["candidates"].items(): if cid and c.get("cid") == cid: return k for k, c in b["candidates"].items(): if _norm(k.split(" (")[0]) == _norm(name) and _same_tier(c["level"], level) and _same_place(c, name, parent, code, cid): if cid and not c.get("cid"): c["cid"] = cid if code and not c.get("country"): c["country"] = code return k key = name if key in b["candidates"]: key = f"{name} ({parent or code})" b["candidates"][key] = {"level": level, "parent": parent, "bbox": None, "scan_bbox": None, "prior": 1.0, "status": "open", "note": why, "country": code, "cid": cid} return key def _country_key(b: dict, m: dict) -> str | None: """Existing country-level candidate for a region match (by pack code or any of the pack's names), if any.""" for k, c in b["candidates"].items(): if c["level"] != "country": continue if c.get("country") == m["country_code"]: return k p = regions.resolve(k.split(" (")[0]) if p and p["code"] == m["country_code"]: c["country"] = m["country_code"] return k return None def cmd_apply(args, p: Path) -> None: b = _load(p) cl = HERE / "clues.py" country = args.country or b.get("country") # The country context only narrows region-pack lookups. Global kinds read --country as an observation target # (driving-side --country X = "which side does X drive on"), so passing the context there would overwrite the value. is_global = args.kind in GLOBAL_KINDS cmd = [UV, "run", str(cl), "lookup", args.kind, args.value, "--json"] + (["--country", country] if country and not is_global else []) r = _run(cmd) if r.returncode != 0: sys.exit(f"clues.py failed: {(r.stderr or r.stdout).strip()[-400:]}") try: d = json.loads(r.stdout[r.stdout.index("{"):]) except Exception: # noqa: BLE001 sys.exit(f"clues.py output isn't JSON: {r.stdout[:300]}") matches = d.get("matches") or [] kid = args.clue if not kid: kid = f"K{len(b['clues']) + 1}" b["clues"][kid] = {"text": f"{args.kind} {args.value}", "kind": args.kind, "status": "read", "file": args.file or "", "source": d.get("source", ""), "used": False} if not matches: what = "Lookup not supported" if d.get("status") == "unsupported" else "Lookup returned nothing" print(f"{what}: {d.get('note', '')}. Clue {kid} recorded, no evidence added.") _save(p, b) return lr = args.lr touched: dict[tuple[str, str], None] = {} why = f"lookup {args.kind}={args.value}" def hit(lv: str, cname: str) -> None: lv = b["candidates"][cname]["level"] # an existing candidate keeps its level (children may have recorded a prefecture as district) if (lv, cname) in touched: # one piece of evidence per place, however many table rows name it return touched[(lv, cname)] = None b["evidence"].append({"id": f"E{len(b['evidence']) + 1}", "clue": kid, "candidate": cname, "lr": lr, "why": why, "file": args.file or "", "cmd": f"clues.py lookup {args.kind} {args.value}"}) for m in matches: code = m.get("country_code") if not code: # global tables: country level only if m.get("country"): hit("country", _ensure(b, "country", m["country"], None, None, None, f"from lookup {args.kind}")) continue ck = _country_key(b, m) if ck: # only when countries are already being compared; a single-country board isn't given a country row hit("country", ck) parent = None for lv in ("admin1", "admin2", "city", "district"): name = m.get(lv) if not name: continue hit(lv, _ensure(b, lv, name, parent, code, regions.cid(code, m, upto=lv), f"from lookup {args.kind}")) parent = name # Finest place each match names: candidates inside it (a district of a pointed-to city) aren't "elsewhere" leaves = {_norm(next((m[lv] for lv in ("district", "city", "admin2", "admin1", "country") if m.get(lv)), "")) for m in matches} # Other non-excluded candidates at the same level: against, but not excluded (lookups have exceptions too: out-of-town vehicles, # headquarters phone numbers). Only candidates the lookup could have pointed to: a country whose pack has no such table # (or isn't installed) was never consulted, so "not pointed to" says nothing about it. searched = set(d.get("searched") or []) ctx = (regions.resolve(country) or {}).get("code") if country else None def covered(k: str, c: dict) -> bool: if is_global: return True code = c.get("country") if c["level"] == "country": # A country row is its own country: the context mustn't stand in for one we can't identify (India with no pack) code = code or (regions.resolve(k.split(" (")[0]) or {}).get("code") return code in searched if code or ctx: return (code or ctx) in searched # Hand-added candidate of unknown country: covered if a searched pack's admin table knows the name return any(pk["code"] in searched for pk, _ in regions.admin_find(k.split(" (")[0])) for lv in {lv for lv, _ in touched}: for k, c in b["candidates"].items(): if c["level"] == lv and (lv, k) not in touched and c.get("status") != "excluded" and _norm(c.get("parent") or "") not in leaves \ and covered(k, c): b["evidence"].append({"id": f"E{len(b['evidence']) + 1}", "clue": kid, "candidate": k, "lr": 1 / lr, "why": f"lookup {args.kind}={args.value} doesn't point here", "file": "", "cmd": ""}) b["clues"][kid]["used"] = True _log(b, f"apply {args.kind}={args.value} → {[n for _, n in touched]}") _save(p, b) countries = sorted({m.get("country_code") for m in matches if m.get("country_code")}) print(f"{kid}: {args.kind}={args.value} → {', '.join(n for _, n in sorted(touched))} (likelihood ratio {lr:g}; others at the same level 1/{lr:g}, not excluded)" + (f"\n Matches in several countries ({', '.join(countries)}): all kept as hypotheses. If the country is known, `board.py country <cc>` and re-apply." if len(countries) > 1 else "")) if d.get("note") and len(countries) <= 1: print(f" Note: {d['note'][:300]}") def cmd_log(args, p: Path) -> None: b = _load(p) print("\n".join(b.get("log", [])[-args.n:])) print(f"\n{len(b['clues'])} clues, {len(b['evidence'])} pieces of evidence, {len(b['candidates'])} candidates") def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--board", type=Path, default=Path("board.json")) sub = ap.add_subparsers(dest="cmd", required=True) def rank_opts(sp): sp.add_argument("--prior-by", choices=["none", "area"], default="none", help="prior: uniform (default) or by bbox area. There is no by-population option") sp.add_argument("--zoom", type=int, default=16, help="scan pages are computed at this zoom (sports field overview z16, factories z16, buildings z17)") sp.add_argument("--cell", type=int, default=320) sp.add_argument("--cols", type=int, default=5) i = sub.add_parser("init") i.add_argument("--photo", required=True) i.add_argument("--case") i.add_argument("--force", action="store_true") i.add_argument("--country", help="country context when it's already known (code or name): region-pack lookups use only that pack") co = sub.add_parser("country", help="set the country context (code or name; 'none' clears it)") co.add_argument("code") a = sub.add_parser("add") a.add_argument("names", nargs="*") a.add_arg -
clues.py 15.5 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = [] # /// """Lookup-table clues. Tables are local JSON; lookups don't go online. Global tables (data/): lookup calling-code +594 international calling code → country/region lookup driving-side left countries that drive on the left; `driving-side --country Japan` → left lookup territories France overseas territories/dependencies of France; `--continent "South America"` lists only that continent Region-pack tables (regions/<cc>/, see `regions.py list`): lookup <kind> <value> [--country cc] kinds the packs declare, e.g. plate, area-code; without --country every pack with that kind is searched and matches from several countries are all returned; with --country only that pack is used, and a pack that lacks the kind (or isn't installed) answers "unsupported" lookup admin <name> [--country cc] parent chain from a pack's admin table; `admin --children <name>` lists the children list global tables and installed packs with entry counts and fetch dates --json output follows one contract (used by board.py apply): {"kind", "value", "status": ok|no_match|unsupported|error (error = that country's pack is broken), "matches": [...], "searched": [pack codes], "source", "table_fetched", "note"} region matches: {"country", "country_code", "admin1", "admin2"?, "city"?, "district"?, "note", "unverified"?} — levels are explicit global matches: {"country", "continent"?, "subregion"?, "side"?, "utc"?, "note"} Examples: clues.py lookup plate <plate prefix> --country cn clues.py lookup area-code 0817 --json clues.py lookup calling-code +594 clues.py lookup territories France --continent "South America" Tables are refreshed by maintainers with refresh.py. """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) import regions # noqa: E402 DATA = Path(__file__).parent.parent / "data" GLOBAL_TABLES = ("calling_codes", "driving_side", "territories", "country_names") GLOBAL_KINDS = ("calling-code", "driving-side", "territories") # Chinese → English country aliases (common ones only; if not found, retry with the English name) COUNTRY_ZH = { "中国": "China", "日本": "Japan", "英国": "United Kingdom", "法国": "France", "美国": "United States", "澳大利亚": "Australia", "香港": "Hong Kong", "澳门": "Macau", "美属维尔京群岛": "U.S. Virgin Islands", "直布罗陀": "Gibraltar", "百慕大": "Bermuda", "开曼群岛": "Cayman Islands", "新喀里多尼亚": "New Caledonia", "法属波利尼西亚": "French Polynesia", "马约特": "Mayotte", "加那利群岛": "Canary Islands", "印度": "India", "泰国": "Thailand", "印尼": "Indonesia", "印度尼西亚": "Indonesia", "马来西亚": "Malaysia", "新加坡": "Singapore", "新西兰": "New Zealand", "南非": "South Africa", "巴西": "Brazil", "墨西哥": "Mexico", "德国": "Germany", "意大利": "Italy", "西班牙": "Spain", "俄罗斯": "Russia", "韩国": "South Korea", "越南": "Vietnam", "菲律宾": "Philippines", "巴基斯坦": "Pakistan", "孟加拉国": "Bangladesh", "斯里兰卡": "Sri Lanka", "尼泊尔": "Nepal", "肯尼亚": "Kenya", "爱尔兰": "Ireland", "加拿大": "Canada", "法属圭亚那": "French Guiana", "荷兰": "Netherlands", "葡萄牙": "Portugal", "丹麦": "Denmark", "挪威": "Norway", "瑞典": "Sweden", "芬兰": "Finland", "阿根廷": "Argentina", "智利": "Chile", "秘鲁": "Peru", "哥伦比亚": "Colombia", "埃及": "Egypt", "土耳其": "Turkey", "伊朗": "Iran", "沙特阿拉伯": "Saudi Arabia", "阿联酋": "United Arab Emirates", "以色列": "Israel", "缅甸": "Myanmar", "柬埔寨": "Cambodia", "老挝": "Laos", "蒙古": "Mongolia", "朝鲜": "North Korea", "台湾": "Taiwan", "瑞士": "Switzerland", "奥地利": "Austria", "比利时": "Belgium", "波兰": "Poland", "捷克": "Czech Republic", "希腊": "Greece", "乌克兰": "Ukraine", "哈萨克斯坦": "Kazakhstan", "摩洛哥": "Morocco", "尼日利亚": "Nigeria", "埃塞俄比亚": "Ethiopia", "坦桑尼亚": "Tanzania", "乌干达": "Uganda", "莫桑比克": "Mozambique", "纳米比亚": "Namibia", "津巴布韦": "Zimbabwe", "赞比亚": "Zambia", "马耳他": "Malta", "塞浦路斯": "Cyprus", "冰岛": "Iceland", "古巴": "Cuba", "牙买加": "Jamaica", "巴哈马": "Bahamas", "圭亚那": "Guyana", "苏里南": "Suriname", "巴布亚新几内亚": "Papua New Guinea", "斐济": "Fiji", "萨摩亚": "Samoa", "汤加": "Tonga", "阿尔及利亚": "Algeria", "突尼斯": "Tunisia", "塞内加尔": "Senegal", "科特迪瓦": "Ivory Coast", "加纳": "Ghana", "喀麦隆": "Cameroon", "马达加斯加": "Madagascar", "毛里求斯": "Mauritius", "留尼汪": "Réunion", "马提尼克": "Martinique", "瓜德罗普": "Guadeloupe", "波多黎各": "Puerto Rico", "关岛": "Guam", "格陵兰": "Greenland", } CONTINENT_ZH = {"亚洲": ["Asia"], "欧洲": ["Europe"], "非洲": ["Africa"], "大洋洲": ["Oceania"], "北美洲": ["Northern America", "North America"], "南美洲": ["South America"], "美洲": ["Americas"], "加勒比": ["Caribbean"], "中美洲": ["Central America"], "南极洲": ["Antarctica"]} # ---------------------------------------------------------------- read/write def load(table: str) -> dict: f = DATA / f"{table}.json" if not f.exists(): sys.exit(f"{f} not found: reinstall the skill folder, or refetch with `refresh.py {table}`") return json.loads(f.read_text(encoding="utf-8")) def _result(kind, value, matches, source, fetched, note="", status=None): return {"kind": kind, "value": value, "status": status or ("ok" if matches else "no_match"), "matches": matches, "searched": [], "source": source, "table_fetched": fetched, "note": note} def lookup_calling_code(value: str) -> dict: d = load("calling_codes") src, fetched = d["_meta"]["source"][0], d["_meta"]["fetched"] digits = re.sub(r"\D", "", value) if digits.startswith("00"): digits = digits[2:] for L in (3, 2, 1): code = digits[:L] if code in d: hits = d[code] # shared codes like 1 and 7: look at the area code that follows subs = [k for k in d if k.startswith(code + "-") and digits[L:].startswith(k.split("-")[1])] if subs: hits = [h for k in subs for h in d[k]] return _result("calling-code", value, [{"country": h["country"], "utc": h.get("utc", ""), "note": ""} for h in hits], src, fetched) return _result("calling-code", value, [], src, fetched, "no matching country code") _CN_NAMES: dict | None = None def _country_en(name: str) -> str: """Chinese name / alias → the English name the tables use. Checks data/country_names.json (300 countries + aliases) first, then the built-in COUNTRY_ZH.""" global _CN_NAMES n = name.strip() if _CN_NAMES is None: f = DATA / "country_names.json" _CN_NAMES = json.loads(f.read_text(encoding="utf-8")) if f.exists() else {} aliases = _CN_NAMES.get("aliases") or {} en2zh = _CN_NAMES.get("en2zh") or {} if n in aliases: return aliases[n] for en, zh in en2zh.items(): if zh == n: return en return COUNTRY_ZH.get(n, n) def lookup_driving_side(value: str | None, country: str | None) -> dict: d = load("driving_side") src, fetched = d["_meta"]["source"][0], d["_meta"]["fetched"] if country: en = _country_en(country) hit = next(((k, v) for k, v in d.items() if k != "_meta" and k.lower() == en.lower()), None) or \ next(((k, v) for k, v in d.items() if k != "_meta" and en.lower() in k.lower()), None) if not hit: return _result("driving-side", country, [], src, fetched, "country name not in the table; try the English name") k, v = hit return _result("driving-side", country, [{"country": k, "side": v["side"], "note": v.get("note", "")}], src, fetched) side = "left" if (value or "").lower().startswith(("l", "左")) else "right" ms = [{"country": k, "side": v["side"], "note": v.get("note", "")} for k, v in d.items() if k != "_meta" and v["side"] == side] return _result("driving-side", side, ms, src, fetched) def lookup_territories(value: str, continent: str | None) -> dict: d = load("territories") src, fetched = d["_meta"]["source"][0], d["_meta"]["fetched"] en = _country_en(value) sovs = [k for k in d["by_sovereign"] if k.lower() == en.lower()] or [k for k in d["by_sovereign"] if en.lower() in k.lower()] if not sovs: return _result("territories", value, [], src, fetched, "sovereign state not in the table (or it has no territories); former colonies are not in this table") items = [it for k in sovs for it in d["by_sovereign"][k]] if continent: keys = [x.lower() for x in CONTINENT_ZH.get(continent, [continent])] items = [it for it in items if any(k in (it["region"] + " " + it["subregion"]).lower() for k in keys)] return _result("territories", value, [{"country": it["name"], "continent": it["region"], "subregion": it["subregion"], "note": f"{it['status']}; sovereign state {it['sovereign']}"} for it in items], src, fetched) # ---------------------------------------------------------------- region-pack lookups def lookup_region(kind: str, value: str, country: str | None) -> dict: r = regions.lookup(kind, value, country) return {"kind": kind, "value": value, "status": r["status"], "matches": r["matches"], "searched": r.get("searched", []), "source": r.get("source", ""), "table_fetched": r.get("fetched", ""), "note": r.get("note", "")} def lookup_admin(value: str | None, children: str | None, level: str | None, country: str | None) -> dict: want = {"city": "admin2", "county": "district"}.get(level or "", level) if children: p, kids = regions.admin_children(children.strip(), country) if not p: return _result("admin-children", children, [], "", "", "no installed pack has this admin area (use the full name, or gazetteer.py for OSM)", status="unsupported" if country and not regions.resolve(country) else None) rows = [] for k in kids: m = regions.match_from_item(p, k) m.update(code=k.get("code", ""), level=regions.level_name(p, k["level"])) if not want or m["level"] == want: rows.append(m) meta = regions.load_table(p, p["admin_table"]).get("_meta", {}) return _result("admin-children", children, rows, ", ".join(meta.get("source") or []), meta.get("fetched", "")) name = (value or "").strip() rows, srcs = [], set() for p, it in regions.admin_find(name, country): m = regions.match_from_item(p, it) m.update(chain=regions.admin_chain(p, it), code=it.get("code", ""), level=regions.level_name(p, it["level"])) rows.append(m) srcs.add(", ".join(regions.load_table(p, p["admin_table"]).get("_meta", {}).get("source") or [])) return _result("admin", name, rows, " | ".join(sorted(srcs)), "", "" if rows else "no admin area with this name in the installed packs (use the full name)") def cmd_lookup(args) -> None: k = "plate" if args.kind == "plate-prefix" else args.kind if k == "calling-code": res = lookup_calling_code(args.value or "") elif k == "driving-side": res = lookup_driving_side(args.value, args.country) elif k == "territories": res = lookup_territories(args.value or "", args.continent) elif k == "admin": res = lookup_admin(args.value, args.children, args.level, args.country) else: kinds = sorted({x for p in regions.packs().values() for x in (p.get("lookups") or {})}) if k not in kinds: sys.exit(f"kind: {' / '.join([*GLOBAL_KINDS, 'admin', *kinds])} (region kinds come from installed packs: `regions.py list`)") res = lookup_region(k, args.value or "", args.country) if args.json: print(json.dumps(res, ensure_ascii=False, indent=1)) return head = f"[{res['kind']}] {res['value']} → {len(res['matches'])} matches" if res["status"] == "unsupported": head = f"[{res['kind']}] {res['value']} → unsupported" if res.get("searched"): head += f" (packs searched: {', '.join(res['searched'])})" print(head + (f" ({res['note']})" if res["note"] else "")) for m in res["matches"][: args.limit]: if "country_code" in m: chain = " / ".join(m[lv] for lv in regions.MATCH_LEVELS if m.get(lv)) print(f" {m['country_code']}: {chain}" + (f" [{m['level']}]" if m.get("level") else "") + (" (unverified)" if m.get("unverified") else "") + (f" {m['note']}" if m.get("note") else "")) else: print(" " + " / ".join(str(m.get(x)) for x in ("country", "side", "continent", "subregion", "utc") if m.get(x)) + (f" {m['note']}" if m.get("note") else "")) if len(res["matches"]) > args.limit: print(f" … {len(res['matches'])} in total; raise --limit") if res["source"]: print(f"source {res['source']}" + (f" ({res['table_fetched']})" if res["table_fetched"] else "")) def cmd_list(args) -> None: for name in GLOBAL_TABLES: f = DATA / f"{name}.json" if not f.exists(): print(f"{name}: missing") continue m = json.loads(f.read_text(encoding="utf-8")).get("_meta", {}) src = m.get("source") or ["hand-compiled"] print(f"{name}: {m.get('count')} entries, {f.stat().st_size // 1024} KB, fetched {m.get('fetched')}, source {src[0] if isinstance(src, list) else src}") for code, p in sorted(regions.packs().items()): for kind, spec in (p.get("lookups") or {}).items(): try: meta = regions.load_table(p, spec["file"]).get("_meta", {}) except regions.PACK_ERRORS as exc: print(f"{code.lower()}/{kind}: broken ({type(exc).__name__}); `regions.py lint {code.lower()}`") continue print(f"{code.lower()}/{kind}: {meta.get('count')} keys, fetched {meta.get('fetched')}, source {(meta.get('source') or ['?'])[0]}") if p.get("admin_table"): print(f"{code.lower()}/admin: {len(regions.admin_items(p))} areas") def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = ap.add_subparsers(dest="cmd", required=True) lk = sub.add_parser("lookup") lk.add_argument("kind") lk.add_argument("value", nargs="?") lk.add_argument("--country", help="region-pack kinds: restrict to this country's pack (code or name); driving-side: the country to look up") lk.add_argument("--continent") lk.add_argument("--children") lk.add_argument("--level", help="with --children: keep one level (admin2/district; city/county also accepted)") lk.add_argument("--json", action="store_true") lk.add_argument("--limit", type=int, default=40) sub.add_parser("list") args = ap.parse_args() {"lookup": cmd_lookup, "list": cmd_list}[args.cmd](args) if __name__ == "__main__": # Chinese-locale Windows writes GBK by default: m², ñ make it crash, and the Chinese the agent reads comes out garbled sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace") main() -
doctor.py 9.8 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = ["playwright"] # /// """Check geo-sleuth setup without uploading photos or downloading ML models. uv run scripts/doctor.py local tools, data, browser, writable directory uv run scripts/doctor.py --network also probe public service endpoints uv run scripts/doctor.py --json machine-readable results on stdout uv may install this script's Playwright dependency on first use. Network probes only check reachability, not image uploads, API stability, coverage, or model inference. Exit codes: 0 = no failed checks (warnings may remain); 1 = at least one failed check. """ from __future__ import annotations import argparse import asyncio import json import os import platform import shutil import subprocess import sys import tempfile from concurrent.futures import ThreadPoolExecutor from pathlib import Path from _browser import launch_browser from _net import PROXY_HELP, curl_args, resolve_proxy import regions SERVICES = { "Baidu image search": "https://graph.baidu.com/pcpage/index?tpl_from=pc", "Baidu panoramas": "https://mapsv0.bdimg.com/?qt=qsdata&x=0&y=0", "Yandex Images": "https://yandex.com/images/", "Google satellite tiles": "https://mt1.google.com/vt/lyrs=s&x=0&y=0&z=0", "Overpass": "https://overpass-api.de/api/status", "Elevation tiles": "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/0/0/0.png", "Hugging Face": "https://huggingface.co/", } # gsv.py's panorama lookup at a covered point (Shibuya). An HTTP probe isn't enough here: a turned-down endpoint still answers 200. STREET_VIEW = ("https://maps.googleapis.com/$rpc/google.internal.maps.mapsjs.v1.MapsJsInternalService/SingleImageSearch", '[["apiv3",null,null,null,"US",null,null,null,null,null,[[0]]],[[null,null,35.6595,139.7005],50],' '[null,["en","US"],null,null,null,null,null,null,[2],null,[[[2,true,2]]]],[[1,2,3,4,8,6]]]') def check(name: str, status: str, detail: str, fix: str = "") -> dict: return {"name": name, "status": status, "detail": detail, "fix": fix} def probe(item: tuple[str, str], proxy: str | None) -> dict: name, url = item try: result = subprocess.run( ["curl", "-q", "-sSL", "--max-time", "12", *curl_args(proxy), "-A", "geo-sleuth-doctor/1.0", "-o", os.devnull, "-w", "%{http_code}", url], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15) code = result.stdout.strip() if result.returncode: return check(name, "FAIL", f"Connection failed (curl exit {result.returncode}).", "Check DNS, TLS certificates and network access.") if code.isdigit() and 200 <= int(code) < 400: return check(name, "PASS", f"HTTP {code}; reachable (a live operation can still be blocked or rate-limited).") return check(name, "WARN", f"HTTP {code}; server reached, but service access was not confirmed.", "Retry later or inspect the service in a browser. HTTP 403/429 may mean a challenge or rate limit.") except (OSError, subprocess.TimeoutExpired): return check(name, "FAIL", "Probe could not complete within its time limit.", "Check curl and network access, then retry.") def street_view_check(proxy: str | None) -> dict: name = "Google Street View" url, query = STREET_VIEW try: result = subprocess.run( ["curl", "-q", "-sS", "--max-time", "12", *curl_args(proxy), "-X", "POST", "-H", "content-type: application/json+protobuf", "-H", "x-user-agent: grpc-web-javascript/0.1", "--data", query, url], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15) except (OSError, subprocess.TimeoutExpired): return check(name, "FAIL", "Probe could not complete within its time limit.", "Check curl and network access, then retry.") if result.returncode: return check(name, "FAIL", f"Connection failed (curl exit {result.returncode}).", "Check DNS, TLS certificates and network access.") try: pano = json.loads(result.stdout)[1][1][1] except (ValueError, IndexError, TypeError, KeyError): pano = None if isinstance(pano, str) and pano: return check(name, "PASS", "Panorama lookup returned a panorama at a covered test point.") return check(name, "FAIL", f"Panorama lookup failed: {' '.join(result.stdout.split())[:160] or 'empty reply'}", "gsv.py near/sheet won't work. If the reply says the service changed or was turned down, gsv.py needs updating.") async def browser_check(proxy: str | None) -> dict: try: from playwright.async_api import async_playwright async with async_playwright() as p: browser, name = await launch_browser(p, proxy) try: page = await browser.new_page() await page.goto("about:blank") return check("Reverse-search browser", "PASS", f"{name} launched successfully.") finally: await browser.close() except Exception as exc: return check("Reverse-search browser", "WARN", str(exc), "Install Chrome or run `uvx playwright install chromium`. Until then, use intake.py --no-rev.") def diagnose(network: bool, proxy: str | None) -> dict: rows = [check("Python", "PASS" if sys.version_info >= (3, 10) else "FAIL", platform.python_version(), "Use Python 3.10 or later." if sys.version_info < (3, 10) else "")] for tool in ("uv", "curl"): found = (os.environ.get("UV") or shutil.which("uv")) if tool == "uv" else shutil.which(tool) try: result = subprocess.run([found or tool, "--version"], capture_output=True, timeout=5) ok = result.returncode == 0 except (OSError, subprocess.TimeoutExpired): ok = False rows.append(check(tool, "PASS" if ok else "FAIL", "Available." if ok else "Not runnable.", "" if ok else f"Install {tool} and reopen your terminal so it is on PATH.")) try: with tempfile.TemporaryFile(dir=Path.cwd()) as file: file.write(b"geo-sleuth") rows.append(check("Working directory", "PASS", "Writable; reports and .geo-cache can be created here.")) except OSError: rows.append(check("Working directory", "FAIL", "Cannot write here.", "Run from a writable folder.")) data = Path(__file__).resolve().parent.parent / "data" names = ("calling_codes", "driving_side", "territories", "country_names") bad = [] for name in names: try: json.loads((data / f"{name}.json").read_text(encoding="utf-8")) except (OSError, ValueError): bad.append(name) rows.append(check("Global lookup tables", "FAIL" if bad else "PASS", "Missing or invalid: " + ", ".join(bad) if bad else f"All {len(names)} tables are readable.", "Reinstall the complete skill folder (including data/)." if bad else "")) # A broken region pack only disables that country's lookups and tips; it never fails the core for code, pack in sorted(regions.packs().items()): try: errs, _ = regions.lint(pack) except regions.PACK_ERRORS as exc: errs = [str(exc)] rows.append(check(f"Region pack {code.lower()}", "WARN" if errs else "PASS", "; ".join(errs[:2]) if errs else f"{pack.get('name', code)}: lookups {', '.join(pack.get('lookups') or {}) or 'none'}.", f"`regions.py lint {code.lower()}` lists every problem; reinstall regions/{code.lower()}/ or remove it." if errs else "")) route = "configured proxy" if resolve_proxy(proxy) else "direct" rows.append(check("Service connection", "PASS", f"Using {route}.")) rows.append(asyncio.run(browser_check(proxy))) rows.append(check("OCR", "INFO", "Apple Vision preferred; RapidOCR fallback." if sys.platform == "darwin" else "RapidOCR backend.", "uv run ocr.py <photo> tests recognition; this check does not install or run OCR.")) rows.append(check("ML models", "INFO", "Not loaded. match.py and sat_scan.py download model weights on first use; allow time and disk space.")) if network and shutil.which("curl"): with ThreadPoolExecutor(max_workers=8) as pool: rows.extend(pool.map(lambda item: probe(item, proxy), SERVICES.items())) rows.append(street_view_check(proxy)) else: rows.append(check("Network probes", "SKIP", "Run with --network to check service reachability.")) return {"ok": not any(row["status"] == "FAIL" for row in rows), "connection": route, "checks": rows} def main() -> None: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--network", action="store_true", help="probe public endpoints without uploading photos") parser.add_argument("--proxy", help=PROXY_HELP) parser.add_argument("--json", action="store_true", help="print JSON for issue reports or automation") args = parser.parse_args() report = diagnose(args.network, args.proxy) if args.json: print(json.dumps(report, ensure_ascii=False, indent=2)) else: for row in report["checks"]: print(f"[{row['status']}] {row['name']}: {row['detail']}") if row["fix"]: print(f" Next: {row['fix']}") print("\nNo failed checks. Review warnings before using optional features." if report["ok"] else "\nSome checks failed. Follow the suggested fixes and rerun doctor.py.") sys.exit(0 if report["ok"] else 1) if __name__ == "__main__": sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace") main() -
evidence.py 7.9 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = ["pillow", "pillow-heif"] # /// """Make the evidence image: mark the camera position, heading wedge and key features on satellite imagery, with street-view comparison panels below. Input is a JSON spec file: { "map": "area.jpg", # required: output of tiles.py fetch (needs the same-name .json next to it) "crop": [x0, y0, x1, y1], # optional, crop in original-image pixels "width": 1280, # output width "camera": [lat, lon], "heading": 52, "hfov": 54, "range_m": 560, # heading wedge "labels": [{"at": [lat, lon], "text": "Xinghe Twin Towers", "color": "#ffdd55", "dx": 0, "dy": 0}], "lines": [{"from": [lat, lon], "bearing": 47, "length_m": 75, "color": "#00ffff"}], "panels": [{"image": "sv1.jpg", "caption": "Street view: same road, looking northeast"}] } All coordinates are WGS84, and the map must be WGS84-aligned imagery as tiles.py fetches it. A GCJ-02 mosaic (Amap and other Chinese road maps) shifts every mark by several hundred meters in China; use tiles.py imagery instead. Convert GCJ-02 input coordinates to WGS84 with geo.py convert --from gcj --to wgs. Paths are relative to the spec file. Example: evidence.py spec.json --out evidence.jpg """ from __future__ import annotations import argparse from _image import open_photo import json import re import sys from pathlib import Path from PIL import Image, ImageDraw sys.path.insert(0, str(Path(__file__).parent)) import geo # noqa: E402 from baidu_pano import _font # noqa: E402 from tiles import Mosaic # noqa: E402 # required keys of each list item in the spec ITEM_KEYS = {"labels": ("at", "text"), "lines": ("from", "bearing", "length_m"), "panels": ("image",)} def check_spec(spec: dict, base: Path) -> list[str]: """Problems that would otherwise surface as a bare KeyError or FileNotFoundError mid-drawing.""" if not isinstance(spec, dict): return ["the spec must be a JSON object"] errs = [] if "map" not in spec: errs.append("missing 'map': the tiles.py fetch mosaic to draw on") elif not isinstance(spec["map"], str) or not spec["map"].strip(): errs.append("'map' must be a non-empty path string") else: map_path = base / spec["map"] if not map_path.is_file(): errs.append(f"map not found: {map_path}") if not map_path.with_suffix(".json").is_file(): errs.append(f"map has no georeference: {map_path.with_suffix('.json')} is missing (tiles.py fetch writes it next to the image)") for key, need in ITEM_KEYS.items(): items = spec.get(key, []) if not isinstance(items, list): errs.append(f"'{key}' must be a JSON array") continue for i, item in enumerate(items): if not isinstance(item, dict): errs.append(f"{key}[{i}] must be a JSON object") continue miss = [k for k in need if k not in item] if miss: errs.append(f"{key}[{i}] is missing {', '.join(repr(k) for k in miss)}") if key == "panels" and "image" in item: if not isinstance(item["image"], str) or not item["image"].strip(): errs.append(f"panels[{i}] 'image' must be a non-empty path string") elif not (base / item["image"]).is_file(): errs.append(f"panels[{i}] image not found: {base / item['image']}") return errs def build(spec: dict, base: Path, out: Path) -> None: errs = check_spec(spec, base) if errs: sys.exit("bad spec:\n " + "\n ".join(errs) + "\nspec format: evidence.py --help") map_path = base / spec["map"] m = Mosaic(map_path) im = Image.open(map_path).convert("RGBA") scale_font = max(im.size) / 1400 ov = Image.new("RGBA", im.size, (0, 0, 0, 0)) d = ImageDraw.Draw(ov) if "camera" in spec: cam = tuple(spec["camera"]) if "heading" in spec: h, fov, rng = spec["heading"], spec.get("hfov", 54), spec.get("range_m", 400) pts = [m.to_px(*cam)] + [m.to_px(*geo.dest(cam, h - fov / 2 + k * fov / 24, rng)) for k in range(25)] d.polygon(pts, fill=(255, 220, 0, 55), outline=(255, 220, 0, 210)) for ln in spec.get("lines", []): a = m.to_px(*ln["from"]) b = m.to_px(*geo.dest(tuple(ln["from"]), ln["bearing"], ln["length_m"])) d.line([a, b], fill=ln.get("color", "#00ffff"), width=int(8 * scale_font) or 3) if "camera" in spec: x, y = m.to_px(*spec["camera"]) r = 18 * scale_font d.ellipse([x - r, y - r, x + r, y + r], fill=(255, 40, 40, 255), outline="white", width=4) im = Image.alpha_composite(im, ov).convert("RGB") d = ImageDraw.Draw(im) f = _font(int(42 * scale_font)) for lb in spec.get("labels", []): ax, ay = m.to_px(*lb["at"]) r = 8 * scale_font d.ellipse([ax - r, ay - r, ax + r, ay + r], fill=lb.get("color", "white"), outline="black") # anchor point x, y = ax + lb.get("dx", 12), ay + lb.get("dy", -12) bb = d.textbbox((0, 0), lb["text"], font=f) tw, th = bb[2] - bb[0], bb[3] - bb[1] x = min(max(8, x), im.size[0] - tw - 16) # stay inside the image edges y = min(max(8, y), im.size[1] - th - 16) d.rectangle([x - 8, y - 6, x + tw + 8, y + th + 10], fill="black") d.text((x, y), lb["text"], font=f, fill=lb.get("color", "white")) if spec.get("crop"): im = im.crop(tuple(spec["crop"])) W = spec.get("width", 1280) im = im.resize((W, int(im.size[1] * W / im.size[0]))) panels = spec.get("panels", []) if panels: pw = W // len(panels) ph = int(pw * 3 / 4) cf = _font(20) lines_per = [] for p in panels: # wrap captions to the panel width so they don't overlap the neighboring panel cap, cur, lines = p.get("caption", ""), "", [] for ch in cap: if cf.getlength(cur + ch) > pw - 12: lines.append(cur) cur = ch else: cur += ch lines.append(cur) lines_per.append(lines[:3]) cap_h = 10 + 26 * max(len(x) for x in lines_per) S = Image.new("RGB", (W, im.size[1] + ph + cap_h), "black") S.paste(im, (0, 0)) d = ImageDraw.Draw(S) for k, (p, lines) in enumerate(zip(panels, lines_per)): pim = open_photo(base / p["image"], "panel image").convert("RGB") pim.thumbnail((pw, ph)) # keep aspect ratio, centered with black borders S.paste(pim, (k * pw + (pw - pim.width) // 2, im.size[1] + cap_h + (ph - pim.height) // 2)) for li, text in enumerate(lines): d.text((k * pw + 6, im.size[1] + 6 + 26 * li), text, font=cf, fill="white") im = S im.save(out, quality=88) def _neg_coords(argv: list[str]) -> list[str]: """argparse takes negative coordinates like -1.45,-48.5 for option names; prefixing a space makes them plain values (float ignores the space). Needed for any case in the southern or western hemisphere.""" return [" " + a if re.match(r"^-\d[\d.]*(,-?[\d.]+)+$", a) else a for a in argv] def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("spec", type=Path) ap.add_argument("--out", type=Path, required=True) args = ap.parse_args(_neg_coords(sys.argv[1:])) build(json.loads(args.spec.read_text(encoding="utf-8")), args.spec.parent, args.out) print(args.out) if __name__ == "__main__": # Chinese Windows outputs GBK by default: it crashes on m², ñ, and Chinese text the agent reads comes out garbled sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace") main() -
exif.py 3.9 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = ["pillow", "pillow-heif"] # /// """Read photo metadata: GPS, capture time, camera model, focal length. The first thing to do before geolocating. exif.py <photo> [<photo> ...] - With GPS: gives WGS84 coordinates and the GCJ-02 conversion (for Amap/Tencent Maps) directly, but you still must check against the image (metadata can be edited). - With capture time: can be fed to sun.py (note EXIF time is usually local time at the capture location, without a time zone; see the OffsetTime field). - With 35mm equivalent focal length: can be fed to geo.py range / geometry.md to compute the field of view, no need to guess the zoom factor. - size is the stored pixel size; a photo with an EXIF orientation also gets orientation and upright_size, the size as a viewer shows it, which is the frame the other scripts' pixel coordinates use (imgprep.py boxes, ocr.py, terrain.py ridge, pose.py points). - WeChat, QQ and most social platforms strip metadata when forwarding; screenshots and rephotographed images have no metadata. Empty output is normal and means nothing. """ from __future__ import annotations import json import sys from pathlib import Path from PIL import ExifTags, Image sys.path.insert(0, str(Path(__file__).parent)) import geo # noqa: E402 from _image import orientation # noqa: E402 try: import pillow_heif pillow_heif.register_heif_opener() except ImportError: # without pillow-heif, HEIC can't be read; other formats work as usual pass def _deg(v, ref) -> float: d, m, s = (float(x) for x in v) val = d + m / 60 + s / 3600 return -val if ref in ("S", "W") else val def read(path: Path) -> dict: im = Image.open(path) ex = im.getexif() out: dict = {"file": str(path), "size": list(im.size)} o = orientation(im) if o != 1: # size is the stored pixels; pixel coordinates for the other scripts are in the upright frame out["orientation"] = o out["upright_size"] = list(im.size) if o <= 4 else list(im.size)[::-1] base = {ExifTags.TAGS.get(k, k): v for k, v in ex.items()} sub = {ExifTags.TAGS.get(k, k): v for k, v in ex.get_ifd(0x8769).items()} for k in ("Make", "Model", "Software"): if base.get(k): out[k.lower()] = str(base[k]).strip("\x00 ") for k in ("DateTimeOriginal", "OffsetTimeOriginal"): if sub.get(k): out[k] = str(sub[k]) if not out.get("DateTimeOriginal") and base.get("DateTime"): out["DateTime"] = str(base["DateTime"]) if sub.get("FocalLengthIn35mmFilm"): out["focal_35mm"] = int(sub["FocalLengthIn35mmFilm"]) lf, sf = geo.fov_from_equiv_focal(out["focal_35mm"]) out["fov_4x3_long_short"] = [round(lf, 1), round(sf, 1)] elif sub.get("FocalLength"): out["focal_mm_actual"] = float(sub["FocalLength"]) g = ex.get_ifd(0x8825) if g and 2 in g and 4 in g: lat, lon = _deg(g[2], g.get(1, "N")), _deg(g[4], g.get(3, "E")) out["gps_wgs84"] = [round(lat, 7), round(lon, 7)] out["gps_gcj02"] = [round(x, 7) for x in geo.convert(lat, lon, "wgs", "gcj")] if 6 in g: out["altitude_m"] = round(float(g[6]), 1) if 17 in g: out["image_direction_deg"] = round(float(g[17]), 1) # some phones write the lens heading return out def main() -> None: if len(sys.argv) < 2: sys.exit(__doc__) for p in sys.argv[1:]: try: print(json.dumps(read(Path(p)), ensure_ascii=False)) except Exception as e: # noqa: BLE001 print(json.dumps({"file": p, "error": str(e)}, ensure_ascii=False)) if __name__ == "__main__": # Chinese Windows outputs GBK by default: it crashes on m², ñ, and Chinese text the agent reads comes out garbled sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace") main() -
gazetteer.py 15.8 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = [] # /// """Admin gazetteer: list candidate areas "in full" with extent, area and scan cost. Used by board.py; can also be queried on its own. children list every next-level (or specified-level) admin area of an admin area, with bbox, bbox area, center info an admin area's OSM relation, admin_level, bbox, center urban an admin area's built-up extent (largest contiguous block of OSM residential/commercial/industrial land use), for scan cost and scan_bbox cost how many cells and pages a bbox takes at tiles.py sheet --grid settings Data comes from OSM Overpass (results cached per query in .geo-cache/osm/). OSM admin lists can have gaps: children merges them with a region pack's admin table when one knows the parent (regions/<cc>/, e.g. China's three levels) and flags entries missing a bbox. admin_level differs by country: China province 4 / prefecture 5 / county 6, France region 4 / department 6, US state 4 / county 6. If unsure, omit --level; the script tries downward from the parent's admin_level and skips levels whose combined extent is under 30% of the parent (China's level 3 is only Hong Kong and Macau). Examples: gazetteer.py children <full province-level name> --out districts.json # municipality → all districts gazetteer.py children <country name> --level 4 # country → first-level admin divisions gazetteer.py info <admin area name> gazetteer.py urban <district name> --within <full province-level name> # built-up bbox + area gazetteer.py cost --bbox <s,w,n,e> --zoom 16 --cell 320 --cols 5 """ from __future__ import annotations import argparse from _net import PROXY_HELP import json import math import os import re import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) import geo # noqa: E402 import osm # noqa: E402 import regions # noqa: E402 LEVEL_NAMES = {"CN": {4: "province", 5: "prefecture", 6: "county", 7: "township"}, "FR": {4: "region", 6: "department", 8: "commune"}, "US": {4: "state", 6: "county", 8: "city"}, "*": {2: "country", 4: "first-level admin division", 6: "second-level admin division", 8: "third-level admin division"}} MIN_COVER = 0.3 # children auto level pick: this level's combined bbox area must be at least this fraction of the parent's bbox def _cache(args) -> Path: return Path(getattr(args, "cache", None) or ".geo-cache/osm") def _bbox_km2(b: list[float]) -> float: s, w, n, e = b return abs(n - s) * 110.574 * abs(e - w) * 111.320 * math.cos(math.radians((s + n) / 2)) def _rel_rows(data: dict) -> list[dict]: rows = [] for el in data.get("elements", []): if el.get("type") != "relation": continue t, b = el.get("tags") or {}, el.get("bounds") or {} if not b: continue bbox = [b["minlat"], b["minlon"], b["maxlat"], b["maxlon"]] rows.append({"name": t.get("name", str(el["id"])), "name_en": t.get("name:en", ""), "osm_id": el["id"], "admin_level": int(t.get("admin_level") or 0), "bbox": [round(v, 5) for v in bbox], "bbox_km2": round(_bbox_km2(bbox), 1), "center": [round((bbox[0] + bbox[2]) / 2, 5), round((bbox[1] + bbox[3]) / 2, 5)], "population": t.get("population", "")}) return rows def find_relation(name: str, proxy: str | None, cache: Path, within: str | None = None, level: int | None = None) -> list[dict]: """Find admin relations by name (there can be several with the same name: Chaoyang District in Beijing and in Changchun), with bbox.""" lv = f'["admin_level"="{level}"]' if level else "" if within: ql = (f'[out:json][timeout:120];rel["name"="{within}"]["boundary"="administrative"];map_to_area->.p;' f'rel(area.p)["name"="{name}"]["boundary"="administrative"]{lv};out tags bb;') else: ql = f'[out:json][timeout:120];rel["name"="{name}"]["boundary"="administrative"]{lv};out tags bb;' rows = _rel_rows(osm.run(ql, proxy, cache)) rows.sort(key=lambda r: (r["admin_level"], -r["bbox_km2"])) return rows def children(parent: str, proxy: str | None, cache: Path, level: int | None, within: str | None) -> tuple[dict, list[dict]]: ps = find_relation(parent, proxy, cache, within) if not ps: sys.exit(f"OSM has no admin relation named \"{parent}\": try the full name (with or without the suffix 市/省/区), or add --within <parent name>") p = ps[0] levels = [level] if level else [p["admin_level"] + k for k in (1, 2, 3, 4)] first = None for lv in levels: ql = (f'[out:json][timeout:180];rel({p["osm_id"]});map_to_area->.a;' f'rel(area.a)["boundary"="administrative"]["admin_level"="{lv}"];out tags bb;') rows = _rel_rows(osm.run(ql, proxy, cache)) rows = [r for r in rows if r["osm_id"] != p["osm_id"]] if len(rows) < 2: continue rows = sorted(rows, key=lambda r: r["name"]) if level: return p, rows # Auto level pick: a level that covers only a small part of the parent isn't the "next level" (China's admin_level 3 is only Hong Kong and Macau; provinces are 4) cover = sum(r["bbox_km2"] for r in rows) / max(p["bbox_km2"], 1e-9) if cover >= MIN_COVER: return p, rows print(f"admin_level {lv} has only {len(rows)} ({', '.join(r['name'] for r in rows[:6])}), " f"together covering only {cover:.1%} of the parent; not treated as the next level, trying lower", file=sys.stderr) first = first or rows if first: print("None of the lower levels covers the parent; falling back to the first level with ≥2; if that's wrong, give --level", file=sys.stderr) return p, first or [] def _local_children(parent: str) -> tuple[list[str], str]: """Names of parent's direct children in a region pack's admin table, and the pack code ([] and "" when no pack knows the parent).""" pk, kids = regions.admin_children(parent) return sorted({k["name"] for k in kids}), (pk["code"] if pk else "") def urban(name: str, proxy: str | None, cache: Path, within: str | None) -> dict: """Built-up area: the largest contiguous block of OSM land-use areas. When land use isn't mapped, fall back to a place=city/town node and estimate a radius from population.""" rs = find_relation(name, proxy, cache, within) if not rs: sys.exit(f"OSM has no admin area named \"{name}\": try the full name or add --within") r = rs[0] ql = (f'[out:json][timeout:180];rel({r["osm_id"]});map_to_area->.a;' f'way["landuse"~"^(residential|commercial|retail|industrial)$"](area.a);out bb;') els = osm.run(ql, proxy, cache).get("elements", []) boxes = [(e["bounds"]["minlat"], e["bounds"]["minlon"], e["bounds"]["maxlat"], e["bounds"]["maxlon"]) for e in els if e.get("bounds")] out = {"name": r["name"], "admin_bbox": r["bbox"], "admin_bbox_km2": r["bbox_km2"], "landuse_polygons": len(boxes)} # Admin seat: the place=city/town node with the largest population; prefer the land-use block nearest to it (when OSM is only half mapped, the largest block is often another town) ql2 = (f'[out:json][timeout:120];rel({r["osm_id"]});map_to_area->.a;' f'node["place"~"^(city|town)$"](area.a);out;') nodes = osm.run(ql2, proxy, cache).get("elements", []) seat = None if nodes: def pop(n): return int(re.sub(r"\D", "", (n.get("tags") or {}).get("population", "0") or "0") or 0) best = max(nodes, key=lambda n: (pop(n), (n.get("tags") or {}).get("place") == "city")) seat = {"name": (best.get("tags") or {}).get("name", ""), "ll": (best["lat"], best["lon"]), "population": pop(best)} out["seat"] = seat if len(boxes) >= 3: # Connected blocks on a 2 km grid; take the block with the largest area cs = 2 / 110.574 cell_of = {} for i, b in enumerate(boxes): cell_of.setdefault((int(((b[0] + b[2]) / 2) / cs), int(((b[1] + b[3]) / 2) / cs)), []).append(i) seen, blocks = set(), [] for c0 in cell_of: if c0 in seen: continue stack, comp = [c0], [] seen.add(c0) while stack: c = stack.pop() comp.extend(cell_of[c]) for di in (-1, 0, 1): for dj in (-1, 0, 1): nb = (c[0] + di, c[1] + dj) if nb in cell_of and nb not in seen: seen.add(nb) stack.append(nb) blocks.append(comp) def block_bbox(comp): return [min(boxes[i][0] for i in comp), min(boxes[i][1] for i in comp), max(boxes[i][2] for i in comp), max(boxes[i][3] for i in comp)] def block_area(comp): return sum(_bbox_km2(list(boxes[i])) for i in comp) chosen, how = None, "" if seat: near = [c for c in blocks if geo.distance(seat["ll"], (((bb := block_bbox(c))[0] + bb[2]) / 2, (bb[1] + bb[3]) / 2)) <= 12000] if near: chosen, how = max(near, key=block_area), f"contiguous land use nearest the admin seat {seat['name']}" if chosen is None: chosen, how = max(blocks, key=block_area), "largest contiguous land use (no admin seat node found; may be another town)" bb = block_bbox(chosen) if seat and not (bb[0] <= seat["ll"][0] <= bb[2] and bb[1] <= seat["ll"][1] <= bb[3]): # The block doesn't cover the admin seat: merge in 1.5 km around the seat s2, w2 = geo.dest(geo.dest(seat["ll"], 180, 1500), 270, 1500) n2, e2 = geo.dest(geo.dest(seat["ll"], 0, 1500), 90, 1500) bb = [min(bb[0], s2), min(bb[1], w2), max(bb[2], n2), max(bb[3], e2)] how += "; merged in 1.5 km around the admin seat" out.update({"urban_bbox": [round(v, 5) for v in bb], "urban_bbox_km2": round(_bbox_km2(bb), 1), "urban_landuse_km2": round(block_area(chosen), 1), "blocks": len(blocks), "source": "landuse", "how": how}) return out if seat: pop = seat["population"] rad = max(1500.0, min(12000.0, math.sqrt(max(pop, 20000) / 6000 / math.pi) * 1000)) # about 6000 people/km² s, w = geo.dest(geo.dest(seat["ll"], 180, rad), 270, rad) n, e = geo.dest(geo.dest(seat["ll"], 0, rad), 90, rad) out.update({"urban_bbox": [round(s, 5), round(w, 5), round(n, 5), round(e, 5)], "urban_bbox_km2": round(_bbox_km2([s, w, n, e]), 1), "source": "place-node", "place": seat["name"], "population": pop, "note": "OSM has no land use mapped; built-up radius estimated from the place node's population, order of magnitude only"}) return out out["note"] = "OSM has neither land use nor a place node: draw scan_bbox yourself on satellite imagery" return out def cost(bbox: list[float], zoom: int, cell: int, cols: int) -> dict: s, w, n, e = bbox mpp = 156543.03392 * math.cos(math.radians((s + n) / 2)) / 2 ** zoom step = cell * mpp * 0.9 rows_ = max(1, math.ceil((n - s) * 110574 / step)) cols_ = max(1, math.ceil((e - w) * 111320 * math.cos(math.radians((s + n) / 2)) / step)) cells = rows_ * cols_ return {"bbox": bbox, "km2": round(_bbox_km2(bbox), 1), "zoom": zoom, "cell_px": cell, "cell_m": round(cell * mpp), "cells": cells, "pages": math.ceil(cells / (cols * cols)), "per_page": cols * cols} def _neg_coords(argv: list[str]) -> list[str]: return [" " + a if re.match(r"^-\d[\d.]*(,-?[\d.]+)+$", a) else a for a in argv] def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--proxy", default=os.environ.get("GEO_PROXY"), help=PROXY_HELP) ap.add_argument("--cache", type=Path, default=Path(".geo-cache/osm")) sub = ap.add_subparsers(dest="cmd", required=True) def common(sp): sp.add_argument("--proxy", default=argparse.SUPPRESS) sp.add_argument("--cache", type=Path, default=argparse.SUPPRESS) c = sub.add_parser("children", help="every subordinate admin area of an admin area (with bbox); use when candidates must be listed \"in full\"") common(c) c.add_argument("name") c.add_argument("--level", type=int, help="OSM admin_level; if omitted, try downward from the parent") c.add_argument("--within", help="parent admin area name, to disambiguate same names") c.add_argument("--out", type=Path, help="write {name: {bbox, bbox_km2, center, ...}}") i = sub.add_parser("info") common(i) i.add_argument("name") i.add_argument("--within") i.add_argument("--level", type=int) u = sub.add_parser("urban", help="built-up extent: scan cost is computed from this, not from the whole admin area") common(u) u.add_argument("name") u.add_argument("--within") u.add_argument("--out", type=Path) k = sub.add_parser("cost", help="how many pages a bbox takes at tiles.py sheet --grid settings") k.add_argument("--bbox", required=True) k.add_argument("--zoom", type=int, default=16) k.add_argument("--cell", type=int, default=320) k.add_argument("--cols", type=int, default=5) args = ap.parse_args(_neg_coords(sys.argv[1:])) cache = _cache(args) if args.cmd == "cost": print(json.dumps(cost([float(v) for v in args.bbox.split(",")], args.zoom, args.cell, args.cols), ensure_ascii=False)) return if args.cmd == "info": rows = find_relation(args.name, args.proxy, cache, args.within, args.level) if not rows: sys.exit("Not found; try the full name or add --within") for r in rows[:8]: print(json.dumps(r, ensure_ascii=False)) return if args.cmd == "urban": out = urban(args.name, args.proxy, cache, args.within) if args.out: args.out.write_text(json.dumps(out, ensure_ascii=False, indent=1), encoding="utf-8") print(json.dumps(out, ensure_ascii=False, indent=1)) return if args.cmd == "children": p, rows = children(args.name, args.proxy, cache, args.level, args.within) local, code = _local_children(p["name"]) names = {r["name"] for r in rows} pk = regions.resolve(code) or {} missing = [n for n in local if n not in names and not any(regions.strip_suffix(n, pk) == regions.strip_suffix(m, pk) for m in names)] out = {r["name"]: {k: r[k] for k in ("osm_id", "admin_level", "bbox", "bbox_km2", "center", "name_en")} for r in rows} for n in missing: out[n] = {"osm_id": None, "admin_level": None, "bbox": None, "bbox_km2": None, "center": None, "name_en": "", "note": "only in the local admin table, no OSM relation found: fill in the bbox with info/urban, or estimate from the parent's extent for now"} lvl = rows[0]["admin_level"] if rows else None print(f"{p['name']} (admin_level {p['admin_level']}) children admin_level {lvl}: OSM {len(rows)}" + (f", {code} pack's admin table adds {len(missing)} ({', '.join(missing)})" if missing else "") + ("; no region pack's admin table has this parent, can't check the list is complete" if not local else "")) for name, r in sorted(out.items(), key=lambda kv: -(kv[1]["bbox_km2"] or 0)): b = r["bbox"] print(f" {name:<14} {r['bbox_km2'] or '?':>9} km² {b if b else '(no bbox)'}") if args.out: args.out.write_text(json.dumps(out, ensure_ascii=False, indent=1), encoding="utf-8") print(f"-> {args.out}") if __name__ == "__main__": # Chinese Windows outputs GBK by default: it crashes on m², ñ, and Chinese text the agent reads comes out garbled sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace") main() -
geo.py 63.6 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = ["numpy", "pillow"] # /// """Coordinate-system conversion + bearing/distance + camera geometry. All other scripts import this file. Coordinate systems: wgs WGS84: GPS / Google satellite imagery / OSM gcj GCJ-02: Amap (Gaode) / Tencent / Google China road map bd BD-09 lat/lon: Baidu bdmc Baidu Mercator plane coordinates (the @x,y in Baidu Maps URLs, the x,y of the panorama API) All lat/lon arguments are always in (lat, lon) order. CLI examples: geo.py convert --from bdmc --to wgs 12697689.83 2568072.49 geo.py bearing 22.6045,114.0520 22.6072,114.0564 geo.py dest 22.6045,114.0520 --bearing 47 --dist 120 geo.py range --real 55 --pixels 195 --image-width 1279 --hfov 53 geo.py range --real 300 --pixels 420 --image-width 1080 --hfov 12:70 # zoom unknown: field-of-view range gives a distance range geo.py line --near 22.6060,114.0550 --far 22.6072,114.0564 --range 50:1500 --out line.json geo.py intersect --align1 N1lat,N1lon:F1lat,F1lon --align2 N2lat,N2lon:F2lat,F2lon --sigma 1 geo.py bearings --at <lat,lon> --geojson buildings.geojson --target 306 --tol 8 # bearing first, then identify the structure: which building lies on that bearing geo.py frame --at 22.60,114.10 --anchor A:22.61,114.05:px=1000 --width 1080 --hfov 12:70 \\ --pt B:22.62,114.06:h=300:w=60 --pt C:22.58,114.04:h=150:w=40 # compute before excluding: should B and C be in the frame geo.py spacing --cols '39,133,219,296,369;745,788,829' --line rail.geojson --line-name 城际 --span 32 \\ --center 35.4983,138.7688 --radius 1500 --grid 50 --headings 55:115 --focals 1200:1500 \\ --cx 640 --out spacing.json # pixel columns of a row of bridge piers → camera position, heading, focal length (7.7) """ from __future__ import annotations import argparse import json import math import os import re import sys import time from pathlib import Path # ---------------------------------------------------------------- coordinate-system conversion _A = 6378245.0 _EE = 0.00669342162296594323 def _out_of_china(lat: float, lon: float) -> bool: return not (73.66 < lon < 135.05 and 3.86 < lat < 53.55) def _t_lat(x: float, y: float) -> float: r = -100 + 2 * x + 3 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * math.sqrt(abs(x)) r += (20 * math.sin(6 * x * math.pi) + 20 * math.sin(2 * x * math.pi)) * 2 / 3 r += (20 * math.sin(y * math.pi) + 40 * math.sin(y / 3 * math.pi)) * 2 / 3 r += (160 * math.sin(y / 12 * math.pi) + 320 * math.sin(y * math.pi / 30)) * 2 / 3 return r def _t_lon(x: float, y: float) -> float: r = 300 + x + 2 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * math.sqrt(abs(x)) r += (20 * math.sin(6 * x * math.pi) + 20 * math.sin(2 * x * math.pi)) * 2 / 3 r += (20 * math.sin(x * math.pi) + 40 * math.sin(x / 3 * math.pi)) * 2 / 3 r += (150 * math.sin(x / 12 * math.pi) + 300 * math.sin(x / 30 * math.pi)) * 2 / 3 return r def _gcj_delta(lat: float, lon: float) -> tuple[float, float]: dlat = _t_lat(lon - 105, lat - 35) dlon = _t_lon(lon - 105, lat - 35) rad = lat / 180 * math.pi m = 1 - _EE * math.sin(rad) ** 2 sm = math.sqrt(m) dlat = dlat * 180 / ((_A * (1 - _EE)) / (m * sm) * math.pi) dlon = dlon * 180 / (_A / sm * math.cos(rad) * math.pi) return dlat, dlon def wgs2gcj(lat: float, lon: float) -> tuple[float, float]: if _out_of_china(lat, lon): return lat, lon dlat, dlon = _gcj_delta(lat, lon) return lat + dlat, lon + dlon def gcj2wgs(lat: float, lon: float) -> tuple[float, float]: """Iterative inverse, error < 0.5 m.""" if _out_of_china(lat, lon): return lat, lon wlat, wlon = lat, lon for _ in range(5): glat, glon = wgs2gcj(wlat, wlon) wlat += lat - glat wlon += lon - glon return wlat, wlon _XPI = math.pi * 3000 / 180 def gcj2bd(lat: float, lon: float) -> tuple[float, float]: z = math.hypot(lon, lat) + 0.00002 * math.sin(lat * _XPI) th = math.atan2(lat, lon) + 0.000003 * math.cos(lon * _XPI) return z * math.sin(th) + 0.006, z * math.cos(th) + 0.0065 def bd2gcj(lat: float, lon: float) -> tuple[float, float]: x, y = lon - 0.0065, lat - 0.006 z = math.hypot(x, y) - 0.00002 * math.sin(y * _XPI) th = math.atan2(y, x) - 0.000003 * math.cos(x * _XPI) return z * math.sin(th), z * math.cos(th) _MCBAND = [12890594.86, 8362377.87, 5591021, 3481989.83, 1678043.12, 0] _MC2LL = [ [1.410526172116255e-8, 0.00000898305509648872, -1.9939833816331, 200.9824383106796, -187.2403703815547, 91.6087516669843, -23.38765649603339, 2.57121317296198, -0.03801003308653, 17337981.2], [-7.435856389565537e-9, 0.000008983055097726239, -0.78625201886289, 96.32687599759846, -1.85204757529826, -59.36935905485877, 47.40033549296737, -16.50741931063887, 2.28786674699375, 10260144.86], [-3.030883460898826e-8, 0.00000898305509983578, 0.30071316287616, 59.74293618442277, 7.357984074871, -25.38371002664745, 13.45380521110908, -3.29883767235584, 0.32710905363475, 6856817.37], [-1.981981304930552e-8, 0.000008983055099779535, 0.03278182852591, 40.31678527705744, 0.65659298677277, -4.44255534477492, 0.85341911805263, 0.12923347998204, -0.04625736007561, 4482777.06], [3.09191371068437e-9, 0.000008983055096812155, 0.00006995724062, 23.10934304144901, -0.00023663490511, -0.6321817810242, -0.00663494467273, 0.03430082397953, -0.00466043876332, 2555164.4], [2.890871144776878e-9, 0.000008983055095805407, -3.068298e-8, 7.47137025468032, -0.00000353937994, -0.02145144861037, -0.00001234426596, 0.00010322952773, -0.00000323890364, 826088.5], ] _LLBAND = [75, 60, 45, 30, 15, 0] _LL2MC = [ [-0.0015702102444, 111320.7020616939, 1704480524535203, -10338987376042340, 26112667856603880, -35149669176653700, 26595700718403920, -10725012454188240, 1800819912950474, 82.5], [0.0008277824516172526, 111320.7020463578, 647795574.6671607, -4082003173.641316, 10774905663.51142, -15171875531.51559, 12053065338.62167, -5124939663.577472, 913311935.9512032, 67.5], [0.00337398766765, 111320.7020202162, 4481351.045890365, -23393751.19931662, 79682215.47186455, -115964993.2797253, 97236711.15602145, -43661946.33752821, 8477230.501135234, 52.5], [0.00220636496208, 111320.7020209128, 51751.86112841131, 3796837.749470245, 992013.7397791013, -1221952.21711287, 1340652.697009075, -620943.6990984312, 144416.9293806241, 37.5], [-0.0003441963504368392, 111320.7020576856, 278.2353980772752, 2485758.690035394, 6070.750963243378, 54821.18345352118, 9540.606633304236, -2710.55326746645, 1405.483844121726, 22.5], [-0.0003218135878613132, 111320.7020701615, 0.00369383431289, 823725.6402795718, 0.46104986909093, 2351.343141331292, 1.58060784298199, 8.77738589078284, 0.37238884252424, 7.45], ] def _poly(x: float, y: float, c: list[float]) -> tuple[float, float]: fx = c[0] + c[1] * abs(x) t = abs(y) / c[9] fy = c[2] + c[3] * t + c[4] * t ** 2 + c[5] * t ** 3 + c[6] * t ** 4 + c[7] * t ** 5 + c[8] * t ** 6 return (-fx if x < 0 else fx), (-fy if y < 0 else fy) def bdmc2bd(x: float, y: float) -> tuple[float, float]: c = next(_MC2LL[i] for i, b in enumerate(_MCBAND) if abs(y) >= b) lon, lat = _poly(x, y, c) return lat, lon def bd2bdmc(lat: float, lon: float) -> tuple[float, float]: lat = max(min(lat, 74), -74) c = next(_LL2MC[i] for i, b in enumerate(_LLBAND) if abs(lat) >= b) return _poly(lon, lat, c) def convert(a: float, b: float, src: str, dst: str) -> tuple[float, float]: """a,b are lat,lon for the lat/lon systems; x,y for bdmc.""" if src == dst: return a, b # normalize to wgs first if src == "wgs": lat, lon = a, b elif src == "gcj": lat, lon = gcj2wgs(a, b) elif src == "bd": lat, lon = gcj2wgs(*bd2gcj(a, b)) elif src == "bdmc": lat, lon = gcj2wgs(*bd2gcj(*bdmc2bd(a, b))) else: raise ValueError(src) if dst == "wgs": return lat, lon g = wgs2gcj(lat, lon) if dst == "gcj": return g bd = gcj2bd(*g) if dst == "bd": return bd if dst == "bdmc": return bd2bdmc(*bd) raise ValueError(dst) # ---------------------------------------------------------------- bearing and distance _R = 6371008.8 def distance(p: tuple[float, float], q: tuple[float, float]) -> float: la1, lo1, la2, lo2 = map(math.radians, (*p, *q)) h = math.sin((la2 - la1) / 2) ** 2 + math.cos(la1) * math.cos(la2) * math.sin((lo2 - lo1) / 2) ** 2 return 2 * _R * math.asin(math.sqrt(h)) def bearing(p: tuple[float, float], q: tuple[float, float]) -> float: """Compass bearing from p looking at q, 0=north, clockwise.""" la1, lo1, la2, lo2 = map(math.radians, (*p, *q)) y = math.sin(lo2 - lo1) * math.cos(la2) x = math.cos(la1) * math.sin(la2) - math.sin(la1) * math.cos(la2) * math.cos(lo2 - lo1) return (math.degrees(math.atan2(y, x)) + 360) % 360 def dest(p: tuple[float, float], brg: float, dist_m: float) -> tuple[float, float]: la1, lo1 = map(math.radians, p) b = math.radians(brg) d = dist_m / _R la2 = math.asin(math.sin(la1) * math.cos(d) + math.cos(la1) * math.sin(d) * math.cos(b)) lo2 = lo1 + math.atan2(math.sin(b) * math.sin(d) * math.cos(la1), math.cos(d) - math.sin(la1) * math.sin(la2)) return math.degrees(la2), math.degrees(lo2) # ---------------------------------------------------------------- Web Mercator tiles def ll2px(zoom: int, lat: float, lon: float) -> tuple[float, float]: """lat/lon → global pixel coordinates at this zoom level (256 tiles).""" n = 256 * 2 ** zoom x = (lon + 180) / 360 * n y = (1 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2 * n return x, y def px2ll(zoom: int, x: float, y: float) -> tuple[float, float]: n = 256 * 2 ** zoom lon = x / n * 360 - 180 lat = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n)))) return lat, lon def meters_per_px(zoom: int, lat: float) -> float: return 156543.03392 * math.cos(math.radians(lat)) / 2 ** zoom # ---------------------------------------------------------------- camera geometry def focal_px(image_width_px: float, hfov_deg: float) -> float: """Focal length in pixels under the pinhole model.""" return (image_width_px / 2) / math.tan(math.radians(hfov_deg) / 2) def range_from_size(real_size_m: float, size_px: float, image_width_px: float, hfov_deg: float) -> float: """Known real width of an object and its pixel width in the frame → depth distance (meters).""" return real_size_m * focal_px(image_width_px, hfov_deg) / size_px def angle_from_center(px: float, center_px: float, image_width_px: float, hfov_deg: float) -> float: """Horizontal angle of a point in the frame relative to the optical axis (degrees, right positive). Same for vertical: pass vertical pixels.""" return math.degrees(math.atan((px - center_px) / focal_px(image_width_px, hfov_deg))) # Common phone main-camera field of view (35mm equivalent focal length → long-side/short-side field of view for a 4:3 frame) def fov_from_equiv_focal(focal_mm: float, aspect: tuple[int, int] = (4, 3)) -> tuple[float, float]: diag = 43.2666 w, h = aspect k = diag / math.hypot(w, h) long_side, short_side = w * k, h * k return (math.degrees(2 * math.atan(long_side / 2 / focal_mm)), math.degrees(2 * math.atan(short_side / 2 / focal_mm))) # ---------------------------------------------------------------- sight-line intersection def _enu(p: tuple[float, float], o: tuple[float, float]) -> tuple[float, float]: """Local plane coordinates with o as origin (meters, east, north). Error is negligible within 50 km.""" return ((p[1] - o[1]) * math.cos(math.radians(o[0])) * 111320.0, (p[0] - o[0]) * 110540.0) def _ll(xy: tuple[float, float], o: tuple[float, float]) -> tuple[float, float]: return (o[0] + xy[1] / 110540.0, o[1] + xy[0] / (math.cos(math.radians(o[0])) * 111320.0)) def ray_from_alignment(near: tuple[float, float], far: tuple[float, float]) -> tuple[tuple[float, float], float]: """In the frame, near is in front of far (or vertically aligned) → the camera position is on the extension of far→near, beyond near. Returns (origin, bearing).""" return near, bearing(far, near) def ray_from_sighting(landmark: tuple[float, float], seen_bearing: float) -> tuple[tuple[float, float], float]: """The bearing from the camera position to landmark is seen_bearing → the camera position is on the ray from landmark in the opposite direction.""" return landmark, (seen_bearing + 180) % 360 def intersect_rays(r1, r2) -> tuple[tuple[float, float], float, float, float] | None: """Intersect two rays. Returns (intersection, crossing angle°, distance along ray 1 in m, distance along ray 2 in m); returns None if parallel or the intersection is behind a ray.""" (p1, b1), (p2, b2) = r1, r2 o = p1 x1, y1 = _enu(p1, o) x2, y2 = _enu(p2, o) d1 = (math.sin(math.radians(b1)), math.cos(math.radians(b1))) d2 = (math.sin(math.radians(b2)), math.cos(math.radians(b2))) den = d1[0] * d2[1] - d1[1] * d2[0] if abs(den) < 1e-9: return None t1 = ((x2 - x1) * d2[1] - (y2 - y1) * d2[0]) / den t2 = ((x2 - x1) * d1[1] - (y2 - y1) * d1[0]) / den if t1 < 0 or t2 < 0: return None pt = _ll((x1 + t1 * d1[0], y1 + t1 * d1[1]), o) ang = abs((b1 - b2 + 180) % 360 - 180) return pt, min(ang, 180 - ang), t1, t2 def intersect_with_error(r1, r2, sigma_deg: float) -> dict | None: """Intersection of two rays, plus the maximum distance the intersection moves when each bearing is offset by ±sigma (used as the error radius).""" base = intersect_rays(r1, r2) if base is None: return None pt, ang, t1, t2 = base spread = 0.0 for s1 in (-sigma_deg, sigma_deg): for s2 in (-sigma_deg, sigma_deg): q = intersect_rays((r1[0], r1[1] + s1), (r2[0], r2[1] + s2)) spread = max(spread, math.inf if q is None else distance(pt, q[0])) return {"point": [round(pt[0], 7), round(pt[1], 7)], "crossing_angle_deg": round(ang, 1), "dist_from_ray1_origin_m": round(t1), "dist_from_ray2_origin_m": round(t2), "error_radius_m": None if math.isinf(spread) else round(spread)} # ---------------------------------------------------------------- frame prediction: should a landmark appear in the frame def _band_f(s: str) -> tuple[float, float]: """'65' → (65, 65); '12:70' → (12, 70).""" if ":" in s: a, b = s.split(":") return float(a), float(b) return float(s), float(s) def _landmark(s: str) -> dict: """name:lat,lon[:h=height m][:w=width m][:px=pixel x] → dict.""" parts = s.split(":") d = {"name": parts[0], "ll": _pair(parts[1])} for kv in parts[2:]: k, v = kv.split("=") d[k] = float(v) return d def _drop_m(dist_m: float) -> float: """Height by which Earth curvature + standard atmospheric refraction (k≈0.13) make a distant object look lower.""" return dist_m * dist_m / (2 * _R) * (1 - 0.13) def frame_predict(cam: tuple[float, float], heading: float, hfov: float, width_px: float, pts: list[dict], cam_h: float = 0.0) -> list[dict]: """Given camera position, heading and horizontal field of view, compute for each landmark: angle from the optical axis, pixel x, whether it is in the frame, angular height and angular width.""" f = focal_px(width_px, hfov) out = [] for p in pts: dist = distance(cam, p["ll"]) rel = (bearing(cam, p["ll"]) - heading + 540) % 360 - 180 row = {"name": p["name"], "dist_m": round(dist), "rel_deg": round(rel, 2)} half_w = math.degrees(math.atan2(p.get("w", 0) / 2, dist)) if dist > 0 else 0 row["half_width_deg"] = round(half_w, 3) if abs(rel) < 90: x = width_px / 2 + f * math.tan(math.radians(rel)) row["px"] = round(x) lo = width_px / 2 + f * math.tan(math.radians(rel - half_w)) hi = width_px / 2 + f * math.tan(math.radians(rel + half_w)) row["in_frame"] = "full" if lo >= 0 and hi <= width_px else ("partial" if hi >= 0 and lo <= width_px else "out") else: row["px"], row["in_frame"] = None, "behind" if "h" in p and dist > 0: top = p["h"] - _drop_m(dist) - cam_h row["top_elev_deg"] = round(math.degrees(math.atan2(top, dist)), 3) row["angular_height_deg"] = round(math.degrees(math.atan2(p["h"], dist)), 3) row["height_px"] = round(f * math.tan(math.radians(row["angular_height_deg"]))) out.append(row) return out def occluders(cam: tuple[float, float], pts: list[dict], cam_h: float = 0.0) -> list[str]: """Pairwise check: can a nearer landmark (with w, h given) fully block a farther one.""" notes = [] info = [] for p in pts: d = distance(cam, p["ll"]) info.append((p, d, bearing(cam, p["ll"]))) for near, dn, bn in info: if "w" not in near or "h" not in near: for far, df, bf in info: gap = abs((bf - bn + 540) % 360 - 180) if far is not near and df > dn and gap < 3: notes.append(f"{far['name']} may be blocked by {near['name']}: their bearings differ by only {gap:.2f}°, " f"{near['name']} has no w, h, can't compute (add them and rerun)") continue for far, df, bf in info: if far is near or df <= dn: continue half_n = math.degrees(math.atan2(near["w"] / 2, dn)) half_f = math.degrees(math.atan2(far.get("w", 0) / 2, df)) gap = abs((bf - bn + 540) % 360 - 180) top_n = math.degrees(math.atan2(near["h"] - _drop_m(dn) - cam_h, dn)) top_f = math.degrees(math.atan2(far.get("h", 0) - _drop_m(df) - cam_h, df)) if "h" in far else None if gap + half_f <= half_n and (top_f is None or top_n >= top_f): notes.append(f"{far['name']} may be fully blocked by {near['name']} (bearing difference {gap:.2f}°, {near['name']} half-width {half_n:.2f}°)") elif gap < half_n + half_f and (top_f is None or top_n >= top_f): notes.append(f"{far['name']} is partly blocked by {near['name']} (bearing difference {gap:.2f}°)") elif gap < half_n + half_f and top_f is not None: notes.append(f"{far['name']} rises above {near['name']} and shows over the top ({top_f:.2f}° > {top_n:.2f}°)") return notes def _ring_positions(spec: str) -> dict: """lat,lon:rmin:rmax:rstep:azstep → rings of candidate camera positions centered on a point.""" ll, rmin, rmax, rstep, azstep = spec.split(":") c = _pair(ll) out = {} r = float(rmin) while r <= float(rmax) + 1e-6: az = 0.0 while az < 360 - 1e-6: out[f"R{int(r)}m@{int(az)}"] = list(dest(c, az, r)) az += float(azstep) r += float(rstep) return out # ---------------------------------------------------------------- camera position from evenly spaced structures (spacing) SPACING_DOC = """When a row of evenly spaced structures in the frame (viaduct piers, utility poles, street lights, guardrail posts) lies on a known line on the map, solve for camera position, heading and focal length. Method in references/geometry.md 7.7. Principle: each structure's pixel column x → offset angle from the frame center atan((x-cx)/f) → a ray; intersect the ray with the GeoJSON polyline to get that structure's chainage (distance along the line). When camera position/heading/focal length are all right, the chainage difference between neighbors is constant and equals the standard span (--span). Score pier = dispersion CV of the chainage differences + |log(mean chainage difference / span)| (plus --mono-penalty if the chainage is not monotonic). With spacing alone, the solution is a band along the sight-line direction (in practice scattered within about 800 m). Give --ridge to score jointly with the skyline (as in joint.py: tot = skyline rms + --flat-weight × flat-horizon penalty + --ridge-weight × spacing score), which in practice narrows it to about 300 m. The skyline part downloads elevation tiles (the AWS Terrain Tiles used by terrain.py, cached in --cache). Grouping in --cols: when the foreground breaks the structures into segments, group them with ';'; neighbor differences are only computed within a group (the difference across a break is not one span). The monotonicity check still runs across all columns: the chainage of the whole row along the line must increase all the way or decrease all the way. Output JSON: {"line": {…the selected line…}, "params": {…all parameters of this run…}, "best": <candidates[0]>, "candidates": [{"tot": total score, "rms": skyline rms (null without --ridge), "pier": spacing score, "cam": [lat, lon], "f": focal length px, "H": heading°, "cc": skyline pitch correction° (null without --ridge), "span_m": mean solved span m, "d_first": distance to the first structure m, "d_last": distance to the last structure m}, …top --top sorted by tot ascending]} The field names cam/f/H/cc follow the output of the script written on the spot in that real case (joint.py), so results from both can be compared directly.""" def _spacing_col_val(v) -> float: """One pixel column: a number, [x, …], or {"col": x, "prominence": …} as from imgprep.py piers.""" if isinstance(v, dict): if "col" not in v: sys.exit(f"Structure record in the JSON has no col field: {v}") return float(v["col"]) if isinstance(v, (list, tuple)): return float(v[0]) return float(v) def _spacing_cols(spec: str) -> list[list[float]]: """Pixel-column groups: '39,133,219;745,788' → [[39,133,219],[745,788]]. Also accepts a JSON file ('@path' or ending in .json): list, list[list], list[{"col": …}], {"piers": [{"col": …}, …]} (output of imgprep.py piers), {"groups": [[…]]}, {"cols": […]} are all recognized. Key priority is piers > groups > cols: "cols" in the imgprep.py piers output is the column search range [x0, x1], not structure columns. Each group is sorted ascending; groups are ordered by their first column.""" raw = spec if raw.startswith("@") or raw.lower().endswith(".json"): with open(raw[1:] if raw.startswith("@") else raw, encoding="utf-8") as fh: data = json.load(fh) if isinstance(data, dict): for key in ("piers", "groups", "cols"): if key in data: data = data[key] break else: sys.exit("No piers / groups / cols found in the JSON") if data is None: sys.exit("piers / groups / cols in the JSON is empty") groups = data if (data and isinstance(data[0], (list, tuple))) else [data] groups = [[_spacing_col_val(v) for v in g] for g in groups] else: groups = [[float(v) for v in part.split(",") if v.strip() != ""] for part in raw.split(";")] groups = [sorted(g) for g in groups if g] groups.sort(key=lambda g: g[0]) if not groups: sys.exit("--cols is empty") return groups def _spacing_focals(spec: str, step: float) -> list[float]: """'1200,1281,1350' → list; '1200:1500' → arithmetic sequence with step. That real case used the list 1200,1281,1350,1430,1500.""" if ":" in spec: parts = [float(v) for v in spec.split(":")] lo, hi = parts[0], parts[1] st = parts[2] if len(parts) > 2 else step n = int(round((hi - lo) / st)) return [lo + st * k for k in range(n + 1)] return [float(v) for v in spec.split(",") if v.strip() != ""] def _spacing_band(spec: str) -> tuple[float, float]: a, b = spec.split(":") return float(a), float(b) def _spacing_line(path: str, name: str | None, index: int): """Take one polyline from a GeoJSON, return (Nx2 [lon,lat] list, info dict). --line-name matches a substring of properties.name; for features with the same name (two tracks, one per direction) use --line-index to pick which one. Without a name: if there is only one line use it; if there are several use the longest and print a note.""" with open(path, encoding="utf-8") as fh: g = json.load(fh) feats = g.get("features") if isinstance(g, dict) and g.get("type") == "FeatureCollection" else None if feats is None: feats = [g] if isinstance(g, dict) else list(g) cands = [] for f in feats: geom = f.get("geometry", f) or {} props = f.get("properties") or {} parts = [] if geom.get("type") == "LineString": parts = [geom["coordinates"]] elif geom.get("type") == "MultiLineString": parts = list(geom["coordinates"]) for c in parts: if len(c) < 2: continue ln = sum(math.dist(c[k], c[k + 1]) for k in range(len(c) - 1)) * 111000 cands.append({"name": str(props.get("name") or ""), "coords": c, "length_m": ln}) if not cands: sys.exit(f"No LineString in {path}") if name: hit = [c for c in cands if name in c["name"]] if not hit: names = sorted({c["name"] for c in cands if c["name"]}) sys.exit(f"No line whose name contains {name!r}; names in the file: {names}") if index >= len(hit): sys.exit(f"{len(hit)} lines have a name containing {name!r}; --line-index can go up to {len(hit) - 1} only") pick = hit[index] note = f"line {index} of the {len(hit)} whose name contains {name!r}" elif len(cands) == 1: pick = cands[0] note = "this is the only line in the file" else: pick = max(cands, key=lambda c: c["length_m"]) note = f"the file has {len(cands)} lines and no --line-name was given, so the longest was used; for another line give --line-name / --line-index" info = {"name": pick["name"], "note": note, "points": len(pick["coords"]), "length_m": round(pick["length_m"]), "ends": [[round(pick["coords"][0][1], 6), round(pick["coords"][0][0], 6)], [round(pick["coords"][-1][1], 6), round(pick["coords"][-1][0], 6)]]} return pick["coords"], info def _spacing_frame_note(path: str, d) -> None: """--cols from imgprep.py piers have always been in the upright frame (EXIF orientation applied); a ridge.json without "frame" was written on the stored pixels. The two agree only when the photo has no EXIF orientation.""" if not isinstance(d, dict) or "frame" in d or not d.get("photo"): return photo = Path(d["photo"]) if not photo.is_file(): photo = Path(path).parent / photo try: from _image import open_image, orientation # noqa: PLC0415 — pillow only when a ridge file is given o = orientation(open_image(photo, load=False)) except Exception: # noqa: BLE001 — the photo is only consulted for this note return if o != 1: print(f"warning: {path} has no \"frame\": \"upright\": its ridge points are in the stored frame of {photo} (EXIF orientation {o}), " f"while --cols from imgprep.py piers are in the upright frame. Re-run terrain.py ridge so both are in the same frame.", file=sys.stderr) def _spacing_ridge(path: str) -> dict: """Read the output of terrain.py ridge {"ridge": [[x,y]…], "flat": [x0,x1], "hrow":…, "f0":…}. Also recognizes two handwritten formats: {"760": 826, …} (column → ridgeline row) and a bare [[x,y]…].""" with open(path, encoding="utf-8") as fh: d = json.load(fh) _spacing_frame_note(path, d) out = {"flat": None, "hrow": None, "f0": None} if isinstance(d, list): pts = d else: pts = d.get("ridge") out["flat"] = d.get("flat") out["hrow"] = d.get("hrow") out["f0"] = d.get("f0") if pts is None: pts = [[float(k), float(v)] for k, v in d.items() if str(k).lstrip("-").replace(".", "", 1).isdigit()] if isinstance(pts, dict): pts = [[float(k), float(v)] for k, v in pts.items()] pts = [[float(p[0]), float(p[1])] for p in pts] if len(pts) < 4: sys.exit(f"Too few ridgeline points in {path} ({len(pts)}), need at least 4") out["pts"] = sorted(pts) return out def _spacing_run(args) -> dict: """Grid search over camera position × heading × focal length, computing the spacing score for each (optionally joint with the skyline). Returns the dict written to --out.""" import numpy as np groups = _spacing_cols(args.cols) cols = [c for g in groups for c in g] if len(cols) < 3: sys.exit(f"--cols has only {len(cols)} columns, need at least 3 (geometry.md 7.7 recommends ≥6). " "If you passed a JSON file: in imgprep.py piers output the structure columns are in the piers field; the cols field is the search range") pier = np.array(cols, float) pair_a, pair_b, off = [], [], 0 # neighbor pairs within a group: only neighbors in the same segment are one span for g in groups: pair_a += list(range(off, off + len(g) - 1)) pair_b += list(range(off + 1, off + len(g))) off += len(g) if not pair_a: sys.exit("Every --cols group has only one column; can't compute neighbor spacing") pair_a, pair_b = np.array(pair_a), np.array(pair_b) lat0, lon0 = args.center kx = 111320 * math.cos(math.radians(lat0)) ky = 110540.0 coords, line_info = _spacing_line(args.line, args.line_name, args.line_index) ll = np.array(coords, float) P = np.c_[(ll[:, 0] - lon0) * kx, (ll[:, 1] - lat0) * ky] # local meter coordinates (x east, y north) seg_a, seg_b = P[:-1], P[1:] e = seg_b - seg_a seg_len = np.hypot(*e.T) chain0 = np.r_[0, np.cumsum(seg_len)][:-1] # chainage at the start of each segment hs = np.arange(args.headings[0], args.headings[1] + 1e-9, args.heading_step) focals = _spacing_focals(args.focals, args.focal_step) xs = np.arange(-args.radius, args.radius + 1e-9, args.grid) print(f"Line: {line_info['name'] or '(unnamed)'} {line_info['points']} points {line_info['length_m']} m ({line_info['note']})") print(f"Camera positions {len(xs)}×{len(xs)} cells × headings {len(hs)} × focal lengths {len(focals)}; {len(cols)} pixel columns in {len(groups)} groups, " f"standard span {args.span} m") dem = ridge = None if args.ridge: sys.path.insert(0, str(Path(__file__).resolve().parent)) import terrain # noqa: PLC0415 — only needed for the joint skyline; don't make other subcommands wait for it ridge = _spacing_ridge(args.ridge) hrow = args.hrow if args.hrow is not None else ridge["hrow"] if hrow is None: sys.exit("The --ridge file has no hrow (the image row of the horizon in the photo); give one with --hrow") rp = np.array(ridge["pts"], float) rx, ry = rp[:, 0], rp[:, 1] flat = args.flat if args.flat is not None else (tuple(ridge["flat"]) if ridge["flat"] else None) flx = np.arange(flat[0], flat[1] + 1e-9, args.flat_step) if flat else None az = np.arange(0, 360, args.az_step) dist = args.sky_near * (args.sky_range / args.sky_near) ** (np.arange(args.sky_samples) / (args.sky_samples - 1)) drop = dist ** 2 / (2 * terrain.R_EARTH) * (1 - terrain.K_REFRACTION) # Earth curvature + atmospheric refraction dem = terrain.DEM((lat0, lon0), args.dem_range, args.dem_zoom, args.cache, args.proxy) print(f"Joint skyline: ridgeline {len(rx)} points, horizon row {hrow}, " f"flat-horizon columns {f'{flat[0]:g}–{flat[1]:g}' if flat else 'not used'}, DEM z{args.dem_zoom} radius {args.dem_range} m") res, nfit, t0 = [], 0, time.time() for iy, yy in enumerate(xs): for xx in xs: w = seg_a - np.array([xx, yy]) hor = None for f in focals: offs = np.degrees(np.arctan((pier - args.cx) / f)) # offset angle of each structure from the frame center a = np.radians(hs[:, None] + offs[None, :]) dx, dy = np.sin(a)[..., None], np.cos(a)[..., None] # (heading, structure, 1) den = dx * e[:, 1] - dy * e[:, 0] with np.errstate(divide="ignore", invalid="ignore"): t = (w[:, 0] * e[:, 1] - w[:, 1] * e[:, 0]) / den # ray parameter = distance m u = (w[:, 0] * dy - w[:, 1] * dx) / den # segment parameter 0–1 ok = (t > args.min_dist) & (u >= 0) & (u <= 1) tt = np.where(ok, t, np.inf) i = np.argmin(tt, axis=2) # take the nearest intersection tmin = np.take_along_axis(tt, i[..., None], 2)[..., 0] valid = np.all(np.isfinite(tmin), axis=1) if not valid.any(): continue uu = np.take_along_axis(u, i[..., None], 2)[..., 0] ch = chain0[i] + uu * seg_len[i] # chainage of each structure sp = np.abs(ch[:, pair_b] - ch[:, pair_a]) m = sp.mean(axis=1) cv = sp.std(axis=1) / np.maximum(m, 1e-6) dch = np.diff(ch, axis=1) mono = np.all(dch > 0, axis=1) | np.all(dch < 0, axis=1) ps = cv + np.abs(np.log(np.maximum(m, 1e-3) / args.span)) + (~mono) * args.mono_penalty ps[~valid] = np.inf k = int(np.argmin(ps)) if not np.isfinite(ps[k]) or ps[k] > args.pier_max: continue nfit += 1 H = float(hs[k]) clat, clon = lat0 + yy / ky, lon0 + xx / kx rms = cc = None tot = float(ps[k]) if dem is not None: if hor is None: # compute the horizon once per camera position, shared by all focal lengths g0 = float(dem.sample(np.array([clat]), np.array([clon]))[0]) la, lo = terrain._dest_np(clat, clon, az, dist) hh = dem.sample(la, lo) hor = np.degrees(np.arctan2(hh - drop[None, :] - g0 - args.eye, dist[None, :])).max(axis=1) r_off = np.degrees(np.arctan((rx - args.cx) / f)) # ridgeline points: pixel column → bearing offset r_el = np.degrees(np.arctan((hrow - ry) / f)) # pixel row → elevation angle mr = np.interp((H + r_off) % 360, az, hor, period=360) diff = mr - r_el cc = float(np.clip(np.median(diff), -args.cc_max, args.cc_max)) # pitch correction (horizon row estimate was off) rms = float(np.sqrt(np.mean((diff - cc) ** 2))) fpen = 0.0 if flx is not None: mf = np.interp((H + np.degrees(np.arctan((flx - args.cx) / f))) % 360, az, hor, period=360) fpen = float(np.mean(np.clip(mf - cc - args.flat_margin, 0, None))) # the stretch that should be flat must not show mountains tot = rms + args.flat_weight * fpen + args.ridge_weight * float(ps[k]) res.append({"tot": tot, "rms": rms, "pier": float(ps[k]), "cam": [round(clat, 5), round(clon, 5)], "f": int(f) if float(f).is_integer() else f, "H": H, "cc": cc, "span_m": round(float(m[k]), 1), "d_first": round(float(tmin[k, 0])), "d_last": round(float(tmin[k, -1]))}) if args.progress and (iy + 1) % args.progress == 0: print(f" {iy + 1}/{len(xs)} rows, hits {nfit}, {time.time() - t0:.0f}s", file=sys.stderr) res.sort(key=lambda r: r["tot"]) top = res[:args.top] return {"line": line_info, "params": {"cols": [[int(c) if float(c).is_integer() else c for c in g] for g in groups], "span": args.span, "cx": args.cx, "center": [lat0, lon0], "radius": args.radius, "grid": args.grid, "headings": list(args.headings), "heading_step": args.heading_step, "focals": focals, "pier_max": args.pier_max, "min_dist": args.min_dist, "ridge": args.ridge, "ridge_weight": args.ridge_weight if args.ridge else None, "hrow": (args.hrow if args.hrow is not None else (ridge["hrow"] if ridge else None)), "dem_zoom": args.dem_zoom if args.ridge else None}, "n_fit": nfit, "best": top[0] if top else None, "candidates": top} # ---------------------------------------------------------------- CLI def _pair(s: str) -> tuple[float, float]: a, b = s.split(",") return float(a), float(b) def _neg_coords(argv: list[str]) -> list[str]: """argparse treats negative coordinates like -1.45,-48.5 as option names; prefixing a space makes them plain values (float ignores the space). Needed for any photo in the southern or western hemisphere.""" return [" " + a if re.match(r"^-\d[\d.]*(,-?[\d.]+)+$", a) else a for a in argv] def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = ap.add_subparsers(dest="cmd", required=True) c = sub.add_parser("convert", help="coordinate-system conversion") c.add_argument("--from", dest="src", required=True, choices=["wgs", "gcj", "bd", "bdmc"]) c.add_argument("--to", dest="dst", required=True, choices=["wgs", "gcj", "bd", "bdmc"]) c.add_argument("a", type=float) c.add_argument("b", type=float) b = sub.add_parser("bearing", help="bearing and distance between two points (wgs)") b.add_argument("p", type=_pair) b.add_argument("q", type=_pair) d = sub.add_parser("dest", help="walk a given number of meters from a point along a bearing (wgs)") d.add_argument("p", type=_pair) d.add_argument("--bearing", type=float, required=True) d.add_argument("--dist", type=float, required=True) r = sub.add_parser("range", help="estimate distance from an object's real size and pixel size") r.add_argument("--real", type=float, required=True, help="real size, meters") r.add_argument("--pixels", type=float, required=True, help="pixel size in the frame") r.add_argument("--image-width", type=float, required=True, help="pixel count of the side that hfov refers to") r.add_argument("--hfov", help="field of view of that side (degrees); if the zoom is unknown give a range, e.g. 12:70, to output a distance range") r.add_argument("--equiv-focal", type=float, help="35mm equivalent focal length; if given, the field of view is computed automatically") r.add_argument("--side", choices=["long", "short"], default="short", help="whether image-width is the long side or the short side") f = sub.add_parser("fov", help="equivalent focal length → field of view") f.add_argument("focal", type=float) ln = sub.add_parser("line", help="alignment line: near is in front of far → the extension line the camera position is on; outputs sample points along it") ln.add_argument("--near", type=_pair, required=True, help="lat,lon of the nearer object in the frame") ln.add_argument("--far", type=_pair, required=True, help="lat,lon of the farther object in the frame, aligned with near") ln.add_argument("--range", default="0:2000", help="distance range extending outward from near (meters), e.g. 50:1500") ln.add_argument("--step", type=float, default=100) ln.add_argument("--out", help="write {name:[lat,lon]}, can be passed to tiles.py mark") it = sub.add_parser("intersect", help="intersect two sight lines (with error radius)") it.add_argument("--align1", help="near_lat,near_lon:far_lat,far_lon (first aligned pair)") it.add_argument("--align2", help="second aligned pair, same format") it.add_argument("--sight1", help="lat,lon@bearing: compass bearing from the camera position to this landmark") it.add_argument("--sight2", help="same as above") it.add_argument("--sigma", type=float, default=1.0, help="bearing error of each sight line (degrees), default 1") fr = sub.add_parser("frame", help="frame check before excluding: from a candidate camera position, should a landmark appear in the frame, and would it be blocked", formatter_class=argparse.RawDescriptionHelpFormatter, description=( "Purpose: before you use \"X is not in the frame\" to exclude a candidate camera position or a whole direction, first compute whether X falls in the frame at all.\n" "Heading can be given two ways: --heading directly; or --anchor with an identified landmark and its pixel x in the frame,\n" "and the script back-computes the heading for each field of view (the smaller the field of view, the narrower the frame, the easier nearby landmarks fall out of it).\n" "If the field of view is unknown (video screenshot, crop, zoom), give a range; the script sweeps it and reports over which field-of-view range each landmark enters the frame.\n" "Only a landmark that is \"fully in the frame over the whole field-of-view range and not blocked\" can have its absence used to exclude.")) pos = fr.add_mutually_exclusive_group(required=True) pos.add_argument("--at", action="append", type=_pair, help="candidate camera position lat,lon, repeatable") pos.add_argument("--points", help="candidate camera positions JSON {name:[lat,lon]}") pos.add_argument("--ring", help="lat,lon:min_radius:max_radius:radius_step:bearing_step — place candidate camera positions in rings around a landmark") fr.add_argument("--heading", type=float, help="camera heading (compass angle)") fr.add_argument("--anchor", help="name:lat,lon:px=pixel_x — an identified landmark and its horizontal pixel position in the frame") fr.add_argument("--width", type=float, required=True, help="frame width in pixels (same side as hfov)") fr.add_argument("--hfov", default="65", help="horizontal field of view, single value or range 12:70 (default 65)") fr.add_argument("--pt", action="append", default=[], type=_landmark, help="landmark to check name:lat,lon[:h=height_m][:w=width_m], repeatable; h, w are used for angular height and occlusion") fr.add_argument("--cam-h", type=float, default=0.0, help="camera height, same datum as landmark h (both height above ground or both elevation above sea level)") fr.add_argument("--min-px", type=float, default=12, help="minimum pixel height of a landmark in the frame to count as \"should be visible\"") fr.add_argument("--out", help="write the full result as JSON") bg = sub.add_parser("bearings", help="bearing, angular width and distance of each GeoJSON feature from the camera position; with --target, sorted by bearing difference (bearing first, then identify the structure)") bg.add_argument("--at", type=_pair, required=True, help="camera position lat,lon") bg.add_argument("--geojson", required=True, help="output of osm.py geom (building footprints, lines, points)") bg.add_argument("--target", type=float, help="true bearing of the target (the bearing of that tower in the photo, computed by sun.py compass)") bg.add_argument("--tol", type=float, default=8, help="mark as candidates those within this many degrees of bearing difference") bg.add_argument("--max-dist", type=float, default=3000, help="only consider features within this distance (meters)") bg.add_argument("--out", help="write {name: [lat, lon]}, can be passed to tiles.py mark") sp = sub.add_parser("spacing", help="camera position from a row of evenly spaced structures (bridge piers, utility poles): pixel columns → rays → intersect with the line → neighbor chainage differences should be constant", formatter_class=argparse.RawDescriptionHelpFormatter, description=SPACING_DOC) sp.add_argument("--cols", required=True, help="pixel columns of the structures, comma-separated; when blocked into segments, group with ';' (remember the quotes), e.g. '39,133,219,296,369;745,788,829'. " "Can also be a JSON file (@cols.json); the output of imgprep.py piers (piers field) works directly. " "First check the piers --sheet to remove peaks that aren't structures and split into segments by foreground occlusion; these two are the top reasons for getting no solution") sp.add_argument("--line", required=True, help="the line the structures are on, GeoJSON (railway/power line fetched by osm.py)") sp.add_argument("--line-name", help="pick a line by substring of properties.name; if not given, use the longest line in the file") sp.add_argument("--line-index", type=int, default=0, help="which one to pick when several lines share the name (one track per direction), default 0") sp.add_argument("--span", type=float, required=True, help="standard span m (high-speed rail simply supported box girder 32, utility poles per local standard); if unsure, run once with each of several values") sp.add_argument("--center", type=_pair, required=True, help="center of the candidate area lat,lon (also the origin of the local plane coordinates)") sp.add_argument("--radius", type=float, default=1500, help="half side length of the candidate area m, default 1500 (searches a ±radius square)") sp.add_argument("--grid", type=float, default=50, help="camera-position grid spacing m, default 50") sp.add_argument("--headings", type=_spacing_band, default=(0.0, 360.0), help="heading search range lo:hi (degrees), default 0:360; narrow it if you know the rough heading, much faster") sp.add_argument("--heading-step", type=float, default=0.25, help="heading step (degrees), default 0.25") sp.add_argument("--focals", required=True, help="focal lengths (pixels), list 1200,1281,1350,1430,1500 or range 1200:1500[:step]") sp.add_argument("--focal-step", type=float, default=50, help="step when --focals is a range, default 50") sp.add_argument("--cx", type=float, default=640, help="horizontal center pixel of the frame, default 640 (photo 1280 wide); for a cropped photo compute it from the crop") sp.add_argument("--min-dist", type=float, default=100, help="minimum distance for an intersection to count m, default 100 (filters out false intersections at the camera's feet)") sp.add_argument("--pier-max", type=float, default=0.08, help="solutions with a spacing score above this are dropped, default 0.08") sp.add_argument("--mono-penalty", type=float, default=1.0, help="penalty for non-monotonic chainage (rays hitting both sides of the line), default 1.0") sp.add_argument("--top", type=int, default=25, help="number of top results to output, default 25") sp.add_argument("--progress", type=int, default=10, help="report progress to stderr every this many grid rows, 0=off") sp.add_argument("--out", help="write the result as JSON (structure above)") sp.add_argument("--ridge", help="ridgeline pixel points JSON (output of terrain.py ridge); if given, score jointly with the skyline, downloads elevation tiles") sp.add_argument("--hrow", type=float, help="pixel row of the horizon in the photo; not needed if the --ridge file has hrow") sp.add_argument("--flat", type=_spacing_band, help="column range in the frame that should be a flat horizon x0:x1 (e.g. 0:280); defaults to flat from the --ridge file") sp.add_argument("--flat-step", type=float, default=20, help="sampling step for flat-horizon columns px, default 20") sp.add_argument("--flat-margin", type=float, default=1.0, help="how many degrees the flat horizon may rise above the corrected horizon, default 1.0") sp.add_argument("--flat-weight", type=float, default=1.5, help="weight of the flat-horizon penalty, default 1.5") sp.add_argument("--ridge-weight", "--pier-weight", dest="ridge_weight", type=float, default=2.0, help="weight of the spacing score in joint scoring, default 2.0 (tot = skyline rms + flat-weight×flat-horizon penalty + this weight×spacing score)") sp.add_argument("--cc-max", type=float, default=0.8, help="maximum absolute pitch correction (degrees), default 0.8") sp.add_argument("--eye", type=float, default=1.6, help="camera height above ground m, default 1.6") sp.add_argument("--dem-zoom", type=int, default=13, help="elevation tile zoom level, default 13") sp.add_argument("--dem-range", type=float, default=18000, help="DEM mosaic radius m, default 18000 (must cover the candidate area + the visible mountains)") sp.add_argument("--sky-range", type=float, default=16000, help="how far out the skyline looks m, default 16000") sp.add_argument("--sky-near", type=float, default=100, help="how near the skyline sampling starts m, default 100") sp.add_argument("--sky-samples", type=int, default=400, help="number of distances sampled per bearing, default 400 (dense near, sparse far)") sp.add_argument("--az-step", type=float, default=0.1, help="step of the horizon bearing table (degrees), default 0.1") sp.add_argument("--cache", type=Path, default=Path(".geo-cache/dem"), help="elevation tile cache directory, default .geo-cache/dem (same as terrain.py)") sp.add_argument("--proxy", default=os.environ.get("GEO_PROXY"), help="proxy URL or 'direct'") args = ap.parse_args(_neg_coords(sys.argv[1:])) if args.cmd == "convert": x, y = convert(args.a, args.b, args.src, args.dst) print(f"{x:.7f},{y:.7f}") elif args.cmd == "bearing": print(f"bearing={bearing(args.p, args.q):.1f}deg distance={distance(args.p, args.q):.1f}m") elif args.cmd == "dest": la, lo = dest(args.p, args.bearing, args.dist) print(f"{la:.7f},{lo:.7f}") elif args.cmd == "range": if args.hfov is None: if args.equiv_focal is None: ap.error("--hfov or --equiv-focal is required") lf, sf = fov_from_equiv_focal(args.equiv_focal) lo = hi = lf if args.side == "long" else sf else: lo, hi = _band_f(args.hfov) if lo == hi: dist = range_from_size(args.real, args.pixels, args.image_width, lo) print(f"fov={lo:.1f}deg focal={focal_px(args.image_width, lo):.0f}px range={dist:.0f}m") else: steps = 6 for k in range(steps + 1): hf = lo * (hi / lo) ** (k / steps) # geometric sampling over zoom dist = range_from_size(args.real, args.pixels, args.image_width, hf) print(f"fov={hf:5.1f}deg focal={focal_px(args.image_width, hf):6.0f}px range={dist:7.0f}m") print(f"Field of view {lo:g}–{hi:g}° → distance {range_from_size(args.real, args.pixels, args.image_width, hi):.0f}–" f"{range_from_size(args.real, args.pixels, args.image_width, lo):.0f} m. Until the zoom is pinned down, look for candidates over the whole range") elif args.cmd == "frame": if args.heading is None and not args.anchor: ap.error("--heading or --anchor is required") if args.at: cams = {f"P{i + 1}": list(p) for i, p in enumerate(args.at)} elif args.points: with open(args.points, encoding="utf-8") as fh: cams = {k: v[:2] for k, v in json.load(fh).items()} else: cams = _ring_positions(args.ring) anchor = _landmark(args.anchor) if args.anchor else None lo, hi = _band_f(args.hfov) hfovs = [lo] if lo == hi else [lo * (hi / lo) ** (k / 40) for k in range(41)] pts = args.pt + ([anchor] if anchor else []) W = args.width full: dict = {} summary_rows = [] for cname, cll in cams.items(): cam = tuple(cll) per_hfov = [] status: dict[str, list[float]] = {p["name"]: [] for p in args.pt} min_px: dict[str, float] = {} for hf in hfovs: if anchor: f = focal_px(W, hf) off = math.degrees(math.atan((anchor["px"] - W / 2) / f)) hd = (bearing(cam, anchor["ll"]) - off) % 360 else: hd = args.heading rows = frame_predict(cam, hd, hf, W, pts, args.cam_h) per_hfov.append({"hfov": round(hf, 2), "heading": round(hd, 2), "points": rows}) for r in rows: if r["name"] in status and r["in_frame"] == "full": status[r["name"]].append(hf) if "height_px" in r: min_px[r["name"]] = min(min_px.get(r["name"], 1e9), r["height_px"]) occ = occluders(cam, pts, args.cam_h) full[cname] = {"ll": cll, "frames": per_hfov, "occlusion": occ} verdict = {} for p in args.pt: inside = status[p["name"]] hidden = any(o.startswith(p["name"] + " may be ") for o in occ) whole_range = False if len(hfovs) == 1: v = "fully in frame" if inside else "not fully in frame" elif len(inside) == len(hfovs): v = "in frame over the whole field-of-view range" whole_range = True elif inside: v = f"in frame at field of view {min(inside):.0f}–{max(inside):.0f}°, out of frame otherwise" else: v = "not in frame over the whole field-of-view range" if hidden: v += "; may be blocked" small = False if inside and "h" not in p: v += "; no height h given, not checked whether it is big enough or shows above the foreground" small = True elif inside and min_px.get(p["name"], 0) < args.min_px: v += f"; only {min_px.get(p['name'], 0):.0f} px tall at minimum, may not be visible" small = True can_exclude = (whole_range or (len(hfovs) == 1 and bool(inside))) \ and not hidden and not small verdict[p["name"]] = {"verdict": v, "absence_excludes": can_exclude} full[cname]["verdict"] = verdict summary_rows.append((cname, cll, verdict)) for cname, cll, verdict in summary_rows[:60]: print(f"{cname} {cll[0]:.5f},{cll[1]:.5f}") frames = full[cname]["frames"] for n, v in verdict.items(): print(f" {n}: {v['verdict']} → absence {'can' if v['absence_excludes'] else 'cannot'} exclude") if len(hfovs) == 1: f0 = frames[0] print(f" heading {f0['heading']}°: " + "; ".join( f"{r['name']} x={r['px']} {r['in_frame']}" + (f" height {r['height_px']}px" if "height_px" in r else "") for r in f0["points"])) else: a, b = frames[0], frames[-1] print(f" as field of view goes {a['hfov']}°→{b['hfov']}°, heading is between {a['heading']}°→{b['heading']}°") for o in full[cname]["occlusion"]: print(f" occlusion: {o}") if len(summary_rows) > 60: print(f" …{len(summary_rows)} camera positions in total, see --out for the full result") excl = [c for c, _, v in summary_rows if any(x["absence_excludes"] for x in v.values())] print(f"\nOf {len(summary_rows)} candidate camera positions, {len(excl)} can be excluded by \"a landmark is not in the frame\"" " (provided the landmark really is absent from the photo, and no unlisted nearby building blocks that direction)") if args.out: with open(args.out, "w", encoding="utf-8") as fh: json.dump(full, fh, ensure_ascii=False, indent=1) excl_pts = {c: cams[c] for c in excl} keep_pts = {c: cams[c] for c in cams if c not in excl} base = args.out.rsplit(".", 1)[0] with open(base + "_keep.json", "w", encoding="utf-8") as fh: json.dump(keep_pts, fh, indent=1) with open(base + "_excluded.json", "w", encoding="utf-8") as fh: json.dump(excl_pts, fh, indent=1) print(f"-> {args.out} (kept camera positions {base}_keep.json, excludable ones {base}_excluded.json, both can go straight to tiles.py mark)") elif args.cmd == "bearings": with open(args.geojson, encoding="utf-8") as fh: gj = json.load(fh) rows = [] for f in gj.get("features", []): g = f.get("geometry") or {} t = g.get("type") if t == "Polygon": coords = [(c[1], c[0]) for c in g["coordinates"][0]] elif t == "MultiPolygon": coords = [(c[1], c[0]) for poly in g["coordinates"] for c in poly[0]] elif t == "LineString": coords = [(c[1], c[0]) for c in g["coordinates"]] elif t == "Point": coords = [(g["coordinates"][1], g["coordinates"][0])] else: continue dists = [distance(args.at, c) for c in coords] if min(dists) > args.max_dist: continue brgs = [bearing(args.at, c) for c in coords] mx = sum(math.cos(math.radians(b)) for b in brgs) my = sum(math.sin(math.radians(b)) for b in brgs) center = math.degrees(math.atan2(my, mx)) % 360 rel = [((b - center + 180) % 360) - 180 for b in brgs] props = f.get("properties") or {} tags = props.get("tags") or props name = tags.get("name") or f.get("id") or "" rows.append({"name": str(name), "bearing": round(center, 1), "span_deg": round(max(rel) - min(rel), 1), "dist_m": round(min(dists)), "height": tags.get("height") or tags.get("building:height") or "", "levels": tags.get("building:levels") or "", "kind": tags.get("building") or tags.get("man_made") or tags.get("power") or "", "center": [round(sum(c[0] for c in coords) / len(coords), 6), round(sum(c[1] for c in coords) / len(coords), 6)]}) if args.target is not None: for r in rows: r["d_bearing"] = round(((r["bearing"] - args.target + 180) % 360) - 180, 1) rows.sort(key=lambda r: abs(r["d_bearing"])) else: rows.sort(key=lambda r: r["bearing"]) print(f"{len(rows)} features (within {args.max_dist:.0f} m)" + (f", target bearing {args.target}°, those within ±{args.tol}° marked *" if args.target is not None else "")) print(f"{'':1} {'brg':>6} {'span':>5} {'dist':>6} {'h/lvl':>7} name/kind") for r in rows[:60]: star = "*" if args.target is not None and abs(r["d_bearing"]) <= args.tol else " " print(f"{star} {r['bearing']:>6.1f} {r['span_deg']:>5.1f} {r['dist_m']:>6} {str(r['height'] or r['levels -
gsv.py 21.9 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = ["pillow"] # /// """Google Street View (the main source outside China): find points, see dates and nearby points, render by heading, build comparison sheets. Usage mirrors baidu_pano.py. Uses the endpoints the Google Maps web page itself calls; no key needed. There is almost no Google Street View coverage in mainland China; for photos from mainland China use baidu_pano.py. Examples: gsv.py near 35.6595,139.7005 --radius 50 # nearest panorama point: id, coordinates, capture date, historical captures, address, nearby points gsv.py render XlVh96-Z9lAI5tKrU2O4Yg --heading 90 --out v.jpg gsv.py sheet --at 35.6595,139.7005 --headings 0,60,120,180,240,300 --out around.jpg # look around from a single point gsv.py sheet --ids ID1,ID2 --toward 35.6600,139.7010 --out s.jpg # every point faces the same target gsv.py sheet --points pts.json --heading 90 --date 2018 --out s2018.jpg # only one capture (the year of the street-view watermark in the photo) gsv.py targets --points churches.json --per 2 --out churches.index.json # every target seen from the panoramas around it, for match.py rank --items """ from __future__ import annotations import argparse from _net import curl_args, PROXY_HELP import json import math import os import re import subprocess import sys import threading from concurrent.futures import ThreadPoolExecutor from pathlib import Path from PIL import Image, ImageDraw sys.path.insert(0, str(Path(__file__).parent)) import geo # noqa: E402 from baidu_pano import _font # noqa: E402 UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36" # official Street View car coverage only (user-uploaded panorama photos have ids like CIHM0og…, and the perspective endpoint can't render them) # The Maps JS API's own panorama lookup (the GET endpoint GeoPhotoService.SingleImageSearch was turned down in 2026; same response layout) META = "https://maps.googleapis.com/$rpc/google.internal.maps.mapsjs.v1.MapsJsInternalService/SingleImageSearch" THUMB = ("https://streetviewpixels-pa.googleapis.com/v1/thumbnail?panoid={id}&cb_client=maps_sv.tactile" "&w={w}&h={h}&yaw={yaw:.1f}&pitch={pitch:.1f}&thumbfov={fov:.0f}") # Coverage layer the Maps web client draws: every official panorama inside one z17 tile (~300 m), one request per tile COVERAGE = "https://www.google.com/maps/photometa/ac/v1?pb=!1m1!1smaps_sv.tactile!6m3!1i{x}!2i{y}!3i17!8b1" # Bound lock storage while serializing workers that share a coverage tile. _COVERAGE_LOCKS = [threading.Lock() for _ in range(64)] def _curl(url: str, proxy: str | None, out: Path | None = None, data: str | None = None) -> bytes: cmd = ["curl", "-q", "-s", "-m", "40", "-A", UA] cmd += curl_args(proxy) if out: cmd += ["-o", str(out)] if data is not None: cmd += ["-X", "POST", "-H", "content-type: application/json+protobuf", "-H", "x-user-agent: grpc-web-javascript/0.1", "--data", data] r = subprocess.run(cmd + [url], capture_output=True) return r.stdout class ServiceError(RuntimeError): """Google answered with an error instead of a lookup result (endpoint changed, blocked, rate-limited).""" def _meta_query(lat: float, lon: float, radius: float) -> str: # official Street View car coverage only ([2, true, 2]); the last list asks for address, date, neighbors and history return json.dumps([["apiv3", None, None, None, "US", None, None, None, None, None, [[0]]], [[None, None, lat, lon], radius], [None, ["en", "US"], None, None, None, None, None, None, [2], None, [[[2, True, 2]]]], [[1, 2, 3, 4, 8, 6]]]) def near(lat: float, lon: float, radius: float, proxy: str | None) -> dict | None: """Nearest panorama within radius; None when there is none. Raises ServiceError when the lookup itself fails.""" t = _curl(META, proxy, data=_meta_query(lat, lon, radius)).decode("utf-8", "replace") try: d = json.loads(t) except json.JSONDecodeError: raise ServiceError(f"unreadable reply: {' '.join(t.split())[:160] or 'empty (network?)'}") from None try: code = d[0][0] body = d[1] if len(d) > 1 else None except (IndexError, TypeError, KeyError): raise ServiceError(f"unexpected reply: {t[:160]}") from None if body is None: if code == 5 and "no images" in t.lower(): # [[5, "generic", "Search returned no images."]] return None raise ServiceError(f"error reply: {t[:160]}") out: dict = {} try: out["id"] = body[1][1] loc = body[5][0][1] out["wgs"] = [loc[0][2], loc[0][3]] out["pano_heading"] = round(loc[2][0], 1) if loc[2] else None except (IndexError, TypeError): return None try: out["address"] = " / ".join(x[0] for x in body[3][2]) except (IndexError, TypeError): out["address"] = "" # this panorama's capture date is in body[6][7]; body[5][0][8] holds historical captures [neighbor index, [year, month(, day)]], don't take it as this panorama's date try: out["date"] = _ym(body[6][7]) except (IndexError, TypeError): out["date"] = None nbrs = [] try: for e in body[5][0][3][0]: nbrs.append({"id": e[0][1], "wgs": [e[2][0][2], e[2][0][3]]}) except (IndexError, TypeError): pass hist = [] try: for idx, ym, *_ in body[5][0][8] or []: # user-uploaded panoramas get mixed into historical captures (perspective renders come out gray): ids start with CIHM0og/CIAB/CAoS, length varies from 22 to 28 pid = nbrs[idx]["id"] if idx < len(nbrs) else "" if len(pid) == 22 and not pid.startswith(("CIHM", "CIAB", "CAoS")): hist.append({**nbrs[idx], "date": _ym(ym)}) except (IndexError, TypeError, ValueError): pass out["history"] = sorted(hist, key=lambda h: h["date"], reverse=True) out["dates_seen"] = sorted({d for d in [out["date"], *(h["date"] for h in hist)] if d}) out["neighbors"] = nbrs[:40] return out def _ym(v: list) -> str: return f"{v[0]}-{int(v[1]):02d}" def pick_date(res: dict, date: str) -> dict | None: """Pick one capture ('2018' or '2018-07') from a near() result: the first match among this panorama and its historical captures; None if there is none.""" for p in [{"id": res["id"], "wgs": res["wgs"], "date": res["date"]}, *res.get("history", [])]: if p["date"] and p["date"].startswith(date): return p return None def _coverage_rows(t: str) -> list: """Rows of one coverage reply. An empty tile (sea, no coverage) is []; anything else that isn't a coverage reply (network failure, 429 page, changed layout) raises ServiceError so it isn't mistaken for missing coverage.""" if not t.startswith(")]}'"): raise ServiceError(f"unreadable coverage reply: {' '.join(t.split())[:160] or 'empty (network?)'}") try: d = json.loads(t[t.index("\n") + 1:]) except ValueError: raise ServiceError(f"unreadable coverage reply: {t[:160]}") from None # with panoramas: [null, [null, [rows…]], …]; an empty tile: [null, null, [cells…]] if not isinstance(d, list) or len(d) < 2 or not (d[1] is None or isinstance(d[1], list)): raise ServiceError(f"unexpected coverage reply: {t[:160]}") if d[1] is None: return [] rows = d[1][1] if len(d[1]) > 1 else None if not (rows is None or isinstance(rows, list)): raise ServiceError(f"unexpected coverage reply: {t[:160]}") return rows or [] def tile_panos(x: int, y: int, proxy: str | None, cache: Path) -> list[tuple[str, float, float]]: """All official panoramas in z17 tile (x, y) as (id, lat, lon). An empty tile (sea, no coverage) is []. Only replies that parse as coverage are cached; a failed lookup raises ServiceError and is retried next time.""" cache.mkdir(parents=True, exist_ok=True) x %= 2 ** 17 # tiles wrap at the antimeridian p = cache / f"cov17_{x}_{y}.txt" with _COVERAGE_LOCKS[hash(p.resolve()) % len(_COVERAGE_LOCKS)]: try: rows = _coverage_rows(p.read_text(encoding="utf-8", errors="replace")) except (FileNotFoundError, ServiceError): p.unlink(missing_ok=True) # missing, empty, or invalid cache: fetch again t = _curl(COVERAGE.format(x=x, y=y), proxy).decode("utf-8", "replace") rows = _coverage_rows(t) # Atomic replacement also protects readers in other processes. tmp = p.with_name(f"{p.name}.{os.getpid()}.{threading.get_ident()}.tmp") tmp.write_text(t, encoding="utf-8") os.replace(tmp, p) out = [] for r in rows: try: pid, ll = r[0][0][1], r[0][2][0] # same filter as near(): user-uploaded panoramas can't be rendered as perspective views if len(pid) == 22 and not pid.startswith(("CIHM", "CIAB", "CAoS")): out.append((pid, float(ll[2]), float(ll[3]))) except (IndexError, TypeError, ValueError): continue return out def tiles_around(lat: float, lon: float, max_d: float) -> list[tuple[int, int]]: """z17 tiles covering every point within max_d metres of (lat, lon); tiles get narrower towards the poles.""" dlat = math.degrees(max_d / geo._R) dlon = math.degrees(max_d / (geo._R * max(math.cos(math.radians(lat)), 1e-6))) top = min(lat + dlat, 85.05) bottom = max(lat - dlat, -85.05) x0, y0 = (int(v // 256) for v in geo.ll2px(17, top, lon - min(dlon, 180))) x1, y1 = (int(v // 256) for v in geo.ll2px(17, bottom, lon + min(dlon, 180))) n = 2 ** 17 xs = sorted({x % n for x in range(x0, x1 + 1)}) return [(x, y) for x in xs for y in range(max(y0, 0), min(y1, n - 1) + 1)] def panos_around(lat: float, lon: float, min_d: float, max_d: float, per: int, spacing: float, proxy: str | None, cache: Path) -> list[tuple[str, float, float, float]]: """Up to `per` panoramas between min_d and max_d metres from (lat, lon), nearest first and at least `spacing` m apart. Returns (id, lat, lon, distance). Raises ServiceError when a coverage lookup fails.""" cands = [] for tx, ty in tiles_around(lat, lon, max_d): for pid, la, lo in tile_panos(tx, ty, proxy, cache): d = geo.distance((lat, lon), (la, lo)) if min_d <= d <= max_d: cands.append((d, pid, la, lo)) picked: list[tuple[str, float, float, float]] = [] for d, pid, la, lo in sorted(cands): if all(geo.distance((la, lo), (p[1], p[2])) >= spacing for p in picked): picked.append((pid, la, lo, d)) if len(picked) >= per: break return picked def render(pid: str, heading: float, pitch: float, fov: float, w: int, h: int, proxy: str | None, cache: Path) -> Image.Image: """heading: compass bearing; positive pitch = looking up; fov: horizontal field of view.""" cache.mkdir(parents=True, exist_ok=True) p = cache / f"{pid}_{heading:.0f}_{pitch:.0f}_{fov:.0f}_{w}x{h}.jpg" if not (p.exists() and p.stat().st_size > 2000): _curl(THUMB.format(id=pid, w=w, h=h, yaw=heading % 360, pitch=-pitch, fov=fov), proxy, p) try: return Image.open(p).convert("RGB") except Exception: # noqa: BLE001 return Image.new("RGB", (w, h), "gray") def sheet(items: list[dict], out: Path, proxy: str | None, cache: Path, cols: int = 3, tw: int = 480, th: int = 360) -> None: with ThreadPoolExecutor(8) as ex: ims = list(ex.map(lambda it: render(it["id"], it["heading"], it.get("pitch", 0), it.get("fov", 90), 640, 480, proxy, cache), items)) rows = (len(items) + cols - 1) // cols S = Image.new("RGB", (cols * tw, max(1, rows) * th), "black") d = ImageDraw.Draw(S) f = _font(16) for i, (it, im) in enumerate(zip(items, ims)): x, y = (i % cols) * tw, (i // cols) * th S.paste(im.resize((tw, th)), (x, y)) d.rectangle([x, y, x + tw, y + 22], fill="black") d.text((x + 4, y + 2), it.get("label") or f"{i}: …{it['id'][-8:]} h{it['heading']:.0f}", fill="yellow", font=f) S.save(out, quality=88) def _neg_coords(argv: list[str]) -> list[str]: """argparse treats negative coordinates like -1.45,-48.5 as option names; prefixing a space makes them plain values (float ignores the space). Needed for every puzzle in the southern or western hemisphere.""" return [" " + a if re.match(r"^-\d[\d.]*(,-?[\d.]+)+$", a) else a for a in argv] def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--proxy", default=os.environ.get("GEO_PROXY"), help=PROXY_HELP) ap.add_argument("--cache", type=Path, default=Path(".geo-cache/gsv")) sub = ap.add_subparsers(dest="cmd", required=True) def common(sp): sp.add_argument("--proxy", default=argparse.SUPPRESS, help="can go before or after the subcommand") sp.add_argument("--cache", type=Path, default=argparse.SUPPRESS) n = sub.add_parser("near") common(n) n.add_argument("latlon") n.add_argument("--radius", type=float, default=50) r = sub.add_parser("render") common(r) r.add_argument("id") r.add_argument("--heading", type=float, required=True) r.add_argument("--pitch", type=float, default=0, help="positive = looking up") r.add_argument("--fov", type=float, default=90, help="horizontal field of view") r.add_argument("--width", type=int, default=1024) r.add_argument("--height", type=int, default=768) r.add_argument("--out", type=Path, required=True) s = sub.add_parser("sheet") common(s) g = s.add_mutually_exclusive_group(required=True) g.add_argument("--ids", help="comma-separated panoids") g.add_argument("--at", help="lat,lon: take the nearest panorama point") g.add_argument("--points", type=Path, help="JSON {name:[lat,lon]}: take the nearest panorama point for each") h = s.add_mutually_exclusive_group(required=True) h.add_argument("--heading", type=float) h.add_argument("--headings", help="comma-separated, e.g. 0,60,120,180,240,300") h.add_argument("--toward", help="lat,lon: every point faces this target") s.add_argument("--offset", type=float, default=0) s.add_argument("--pitch", type=float, default=0) s.add_argument("--fov", type=float, default=90) s.add_argument("--radius", type=float, default=50) s.add_argument("--date", help="only this capture (2018 or 2018-07), picked from this panorama and its historical captures; use it for the street-view watermark year in the photo") s.add_argument("--limit", type=int, default=12) s.add_argument("--out", type=Path, required=True) t = sub.add_parser("targets", help="for every target point, the panoramas around it rendered facing it -> .index.json for match.py") common(t) t.add_argument("--points", type=Path, required=True, help="JSON {name:[lat,lon]}, e.g. osm.py find output (every church in a region)") t.add_argument("--per", type=int, default=2, help="panoramas per target") t.add_argument("--min-dist", type=float, default=12, help="skip panoramas closer than this (m): too close shows only a wall") t.add_argument("--max-dist", type=float, default=70, help="m") t.add_argument("--spacing", type=float, default=15, help="minimum distance between the panoramas picked for one target (m)") t.add_argument("--pitch", type=float, default=8) t.add_argument("--fov", type=float, default=90) t.add_argument("--sheet", type=Path, help="also draw the first --limit items as a contact sheet") t.add_argument("--limit", type=int, default=12) t.add_argument("--out", type=Path, required=True, help="writes the .index.json for match.py rank --items") args = ap.parse_args(_neg_coords(sys.argv[1:])) if args.cmd == "near": lat, lon = map(float, args.latlon.split(",")) try: res = near(lat, lon, args.radius, args.proxy) except ServiceError as e: sys.exit(f"Google Street View lookup failed ({e}); run doctor.py --network to check service access") print(json.dumps(res, ensure_ascii=False, indent=1) if res else f"no Google Street View panorama within {args.radius:.0f} m (increase --radius or check coverage)") elif args.cmd == "render": render(args.id, args.heading, args.pitch, args.fov, args.width, args.height, args.proxy, args.cache).save(args.out) print(args.out) elif args.cmd == "targets": pts = json.loads(args.points.read_text(encoding="utf-8")) def around(kv): name, (la, lo) = kv try: return name, (la, lo), panos_around(la, lo, args.min_dist, args.max_dist, args.per, args.spacing, args.proxy, args.cache), None except ServiceError as e: return name, (la, lo), [], e items, empty, failed = [], [], [] with ThreadPoolExecutor(8) as ex: for name, tgt, picked, err in ex.map(around, pts.items()): if err: failed.append(name) print(f"{name}: coverage lookup failed ({err})", file=sys.stderr) continue if not picked: empty.append(name) for pid, la, lo, d in picked: hd = geo.bearing((la, lo), tgt) items.append({"id": pid, "heading": round(hd, 1), "pitch": args.pitch, "fov": args.fov, "label": f"{len(items)}: {str(name)[:18]} {d:.0f}m h{hd:.0f}", "point": name, "wgs": [la, lo], "target": list(tgt), "dist": round(d), "date": ""}) out = args.out if args.out.name.endswith(".index.json") else args.out.with_suffix(".index.json") out.write_text(json.dumps(items, ensure_ascii=False, indent=1), encoding="utf-8") print(f"{len(pts)} targets -> {len(items)} views ({len(empty)} targets without a panorama " f"within {args.min_dist:.0f}–{args.max_dist:.0f} m, {len(failed)} lookups failed) -> {out}") if len(items) > 400: print("match.py loads all candidate images into memory and caps candidates at 400 by default: " "split the target points into smaller batches or narrow the area before ranking", file=sys.stderr) if args.sheet and items: sheet(items[:args.limit], args.sheet, args.proxy, args.cache) print(args.sheet) if failed: # failed tiles aren't cached, so running the same command again retries only those sys.exit(f"Google Street View coverage lookup failed for {len(failed)} of {len(pts)} targets; " "rerun later or run doctor.py --network to check service access") else: panos: dict[str, dict] = {} if args.ids: panos = {pid: {"ll": None, "name": "", "date": ""} for pid in args.ids.split(",")} else: pts = {"at": list(map(float, args.at.split(",")))} if args.at else json.loads(args.points.read_text(encoding="utf-8")) for name, (la, lo) in pts.items(): try: res = near(la, lo, args.radius, args.proxy) except ServiceError as e: sys.exit(f"Google Street View lookup failed ({e}); run doctor.py --network to check service access") if res and args.date: p = pick_date(res, args.date) if not p: print(f"{name}: no {args.date} capture (available: {', '.join(res['dates_seen'])})", file=sys.stderr) continue panos.setdefault(p["id"], {"ll": p["wgs"], "name": name, "date": p["date"]}) elif res: panos.setdefault(res["id"], {"ll": res["wgs"], "name": name, "date": res.get("date") or ""}) else: print(f"{name}: no panorama nearby", file=sys.stderr) target = tuple(map(float, args.toward.split(","))) if args.toward else None items = [] for pid, meta in panos.items(): ll = meta["ll"] if args.headings: heads = [float(x) for x in args.headings.split(",")] elif target: if ll is None: try: info = near(*target, 5000, args.proxy) except ServiceError: info = None # with only an id there are no coordinates, so estimate from near the target; --points is recommended ll = info["wgs"] if info else list(target) heads = [geo.bearing(tuple(ll), target) + args.offset] else: heads = [args.heading] for hd in heads: where = f"{ll[0]:.5f},{ll[1]:.5f}" if ll else f"…{pid[-8:]}" label = " ".join(x for x in (meta["name"][:18], where, meta["date"], f"h{hd % 360:.0f}") if x) items.append({"id": pid, "heading": hd % 360, "pitch": args.pitch, "fov": args.fov, "label": f"{len(items)}: {label}", "point": meta["name"], "wgs": ll, "date": meta["date"]}) pages = [items[k:k + args.limit] for k in range(0, len(items), args.limit)] or [[]] for pi, page in enumerate(pages): out = args.out if pi == 0 else args.out.with_name(f"{args.out.stem}_{pi + 1}{args.out.suffix}") sheet(page, out, args.proxy, args.cache) print(out) args.out.with_suffix(".index.json").write_text(json.dumps(items, indent=1), encoding="utf-8") if __name__ == "__main__": # Chinese-locale Windows writes GBK by default: m², ñ make it crash, and the Chinese the agent reads comes out garbled sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace") main() -
imgprep.py 16.7 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = ["numpy", "pillow", "pillow-heif"] # /// """Image processing before looking at and searching with a photo. zoom crop a region, upscale and sharpen: read small text, count tracks, check plate background color edges one upscaled image for each of the four edges and four corners — ropes in corners, a boat bow at the bottom, small road signs are the easiest to miss variants variants for reverse image search: tight crop / horizontal flip / enhanced grayscale / color-cast removal / upscale; optional perspective rectification grid cut into N×M tiles, for "search only a part" in reverse image search piers take a brightness profile along a few rows below the bridge deck and find the pixel columns of the bridge piers (for back-solving the camera position) Coordinates are always pixels of the whole photo as a viewer shows it (EXIF orientation applied), x0,y0,x1,y1 (top-left, bottom-right); the same frame as ocr.py boxes, terrain.py ridge points and pose.py points. Check the photo size first: `exif.py` size, or its upright_size when it reports an EXIF orientation. Examples: imgprep.py zoom photo.jpg --box 820,40,1080,300 --scale 4 --out sign.png imgprep.py edges photo.jpg --out-dir edges/ imgprep.py variants photo.jpg --box 300,120,900,760 --prefix left --out-dir v/ # → v/left_crop.jpg left_flip.jpg … imgprep.py variants photo.jpg --persp 312,140,880,95,905,770,290,720 --out-dir v/ # rectify four corners (top-left top-right bottom-right bottom-left) imgprep.py grid photo.jpg --rows 2 --cols 3 --out-dir tiles/ imgprep.py piers photo.jpg --rows 926:940 --out cols.json --sheet piers.jpg piers output JSON: {"image": path, "size": [W, H], "rows": [r0, r1], "cols": [x0, x1], "polarity": "bright" | "dark", # whether piers are brighter or darker than their surroundings "params": {"min_gap": 20, "min_prominence": 12, "baseline": 61}, "count": number of peaks, "piers": [{"col": 38, "prominence": 100.5, "dev": 92.4, "level": 183.2}, ...]} col is the pixel column (integer, original-image coordinates), prominence is the peak's topographic prominence (the main basis for judging real vs. false), dev is the height after baseline removal, level is the column's raw mean brightness over the rows range. piers are sorted by col ascending. """ from __future__ import annotations import argparse from _image import open_photo import json import sys from pathlib import Path from PIL import Image, ImageFilter, ImageOps, ImageStat def _box(s: str) -> tuple[int, int, int, int]: x0, y0, x1, y1 = (int(float(v)) for v in s.split(",")) return min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1) def zoom(im: Image.Image, box, scale: float) -> Image.Image: c = im.crop(box) c = c.resize((max(1, int(c.width * scale)), max(1, int(c.height * scale))), Image.LANCZOS) return c.filter(ImageFilter.UnsharpMask(radius=2, percent=120, threshold=2)) def gray_world(im: Image.Image) -> Image.Image: """Gray-world white balance: use when an old photo has a yellow or purple cast.""" r, g, b = ImageStat.Stat(im.convert("RGB")).mean avg = (r + g + b) / 3 chans = [ch.point(lambda v, k=avg / max(m, 1): max(0, min(255, int(v * k)))) for ch, m in zip(im.convert("RGB").split(), (r, g, b))] return ImageOps.autocontrast(Image.merge("RGB", chans), cutoff=1) def perspective(im: Image.Image, quad: list[float]) -> Image.Image: """Four corners (top-left top-right bottom-right bottom-left) → rectangle. Output width and height take the larger of each pair of opposite side lengths.""" (x0, y0), (x1, y1), (x2, y2), (x3, y3) = (quad[i:i + 2] for i in range(0, 8, 2)) w = int(max(((x1 - x0) ** 2 + (y1 - y0) ** 2) ** .5, ((x2 - x3) ** 2 + (y2 - y3) ** 2) ** .5)) h = int(max(((x3 - x0) ** 2 + (y3 - y0) ** 2) ** .5, ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** .5)) # PIL's QUAD transform: the output rectangle's four corners come, in order, from the source's top-left, bottom-left, bottom-right, top-right return im.transform((w, h), Image.QUAD, (x0, y0, x3, y3, x2, y2, x1, y1), Image.BICUBIC) def _range(s: str) -> tuple[int, int]: """Parse "a:b" (a inclusive, b exclusive).""" a, b = s.split(":") a, b = int(float(a)), int(float(b)) return (a, b) if a <= b else (b, a) def _baseline(p, w: int): """Sliding median baseline: flattens slowly varying brightness such as the bridge body, sky and ground, leaving only thin vertical strips.""" import numpy as np h = w // 2 pad = np.pad(p, (h, h), mode="edge") return np.array([float(np.median(pad[i:i + w])) for i in range(len(p))]) def _prominence(d, i: int) -> float: """Topographic prominence of local maximum i: peak height minus the higher of the two valley floors it descends to on the left and right.""" h = d[i] lo = h j = i while j > 0 and d[j - 1] <= h: j -= 1 lo = min(lo, d[j]) left = lo lo = h j = i while j < len(d) - 1 and d[j + 1] <= h: j += 1 lo = min(lo, d[j]) return float(h - max(left, lo)) def _peaks(d, min_gap: int, min_prominence: float) -> list[tuple[int, float]]: """Find peaks in d: prominence must first pass the threshold, then, from highest to lowest prominence, greedily keep those ≥ min_gap apart.""" cand = [i for i in range(1, len(d) - 1) if d[i] >= d[i - 1] and d[i] > d[i + 1]] res = [(i, _prominence(d, i)) for i in cand] res = [(i, pr) for i, pr in res if pr >= min_prominence] res.sort(key=lambda t: -t[1]) keep: list[tuple[int, float]] = [] for i, pr in res: if all(abs(i - j) >= min_gap for j, _ in keep): keep.append((i, pr)) keep.sort() return keep def piers(im: Image.Image, rows: tuple[int, int], cols: tuple[int, int] | None, min_gap: int, min_prominence: float, baseline_win: int, polarity: str) -> dict: """Take each column's mean brightness over the rows below the bridge deck → subtract the sliding median baseline → find peaks = bridge pier pixel columns. In the shadow of a shaded bridge body, piers are usually brighter than their surroundings (polarity=bright); against the light or on overcast days it can be the reverse (dark); auto computes both and takes the one with the larger total prominence. """ import numpy as np W, H = im.size r0, r1 = max(0, rows[0]), min(H, rows[1]) if r1 - r0 < 1: raise SystemExit(f"--rows {rows[0]}:{rows[1]} selects no rows in a {W}x{H} image") x0, x1 = (0, W) if cols is None else (max(0, cols[0]), min(W, cols[1])) a = np.asarray(im.convert("L"), dtype=float) p = a[r0:r1, x0:x1].mean(axis=0) base = _baseline(p, max(3, baseline_win | 1)) dev = p - base opts = {"bright": _peaks(dev, min_gap, min_prominence), "dark": _peaks(-dev, min_gap, min_prominence)} if polarity == "auto" \ else {polarity: _peaks(dev if polarity == "bright" else -dev, min_gap, min_prominence)} pol = max(opts, key=lambda k: sum(pr for _, pr in opts[k])) sign = 1.0 if pol == "bright" else -1.0 found = [{"col": int(i + x0), "prominence": round(pr, 2), "dev": round(float(dev[i]) * sign, 2), "level": round(float(p[i]), 2)} for i, pr in opts[pol]] return {"size": [W, H], "rows": [r0, r1], "cols": [x0, x1], "polarity": pol, "params": {"min_gap": min_gap, "min_prominence": min_prominence, "baseline": baseline_win}, "count": len(found), "piers": found} def piers_sheet(im: Image.Image, r: dict, out: Path) -> None: """Draw the sampled row band and each pier column (numbered) on the original image, so a person can check at a glance for extra or missing ones.""" from PIL import ImageDraw sheet = im.copy() d = ImageDraw.Draw(sheet) r0, r1 = r["rows"] x0, x1 = r["cols"] d.rectangle([x0, r0, x1 - 1, r1 - 1], outline=(0, 200, 255), width=1) for n, pier in enumerate(r["piers"]): x = pier["col"] d.line([(x, max(0, r0 - 60)), (x, min(sheet.height, r1 + 60))], fill=(255, 0, 0), width=1) d.text((x + 2, max(0, r0 - 72)), str(n), fill=(255, 255, 0)) sheet.save(out, quality=92) def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = ap.add_subparsers(dest="cmd", required=True) z = sub.add_parser("zoom") z.add_argument("image", type=Path) z.add_argument("--box", type=_box, required=True) z.add_argument("--scale", type=float, default=3) z.add_argument("--out", type=Path, required=True) e = sub.add_parser("edges") e.add_argument("image", type=Path) e.add_argument("--frac", type=float, default=0.18, help="edge strip as a fraction of the whole image, default 0.18") e.add_argument("--out-dir", type=Path, required=True) v = sub.add_parser("variants") v.add_argument("image", type=Path) v.add_argument("--box", type=_box, help="search only this region (removes sky and background)") v.add_argument("--persp", help="8 numbers: the four corners top-left top-right bottom-right bottom-left, rectified to a front view") v.add_argument("--prefix", help="output file name prefix; default is original name + crop box, so variants of different boxes don't overwrite each other") v.add_argument("--out-dir", type=Path, required=True) g = sub.add_parser("grid") g.add_argument("image", type=Path) g.add_argument("--rows", type=int, default=2) g.add_argument("--cols", type=int, default=2) g.add_argument("--overlap", type=float, default=0.15) g.add_argument("--out-dir", type=Path, required=True) pr = sub.add_parser("piers", help="find bridge pier pixel columns from the brightness profile of a few rows below the bridge deck", formatter_class=argparse.RawDescriptionHelpFormatter, description="""\ Take the brightness profile along the --rows rows, subtract the sliding median baseline, and find peaks with prominence ≥ --min-prominence; the pixel columns of the peaks are the pier columns. For sample rows, pick the rows below the bridge deck where the piers are exposed and the background (sky, distant view, water) is continuous; when the deck is tilted in the frame, widen the row band to cover both ends. Output JSON (--out): {"image": path, "size": [W, H], "rows": [r0, r1], "cols": [x0, x1], # cols is the column search range, not the structure columns "polarity": "bright" | "dark", # whether piers are brighter or darker than their surroundings "params": {"min_gap": 20, "min_prominence": 12, "baseline": 61}, "count": number of peaks, "piers": [{"col": 38, "prominence": 100.5, "dev": 92.4, "level": 183.2}, ...]} col is the pixel column (integer, original-image coordinates), prominence is the peak's prominence (the main basis for judging real vs. false), dev is the height after baseline removal, level is the column's raw mean brightness over the rows range. piers are sorted by col ascending. This JSON can go straight to geo.py spacing --cols @cols.json, but **check --sheet first**: drop non-pier peaks (bright foreground spots, railings, trees); structure segments broken by foreground occlusion must be separated with ';' when you hand-write the --cols string.""") pr.add_argument("image", type=Path) pr.add_argument("--rows", type=_range, required=True, help='sample rows, "r0:r1" (r0 inclusive, r1 exclusive). Take the rows below the bridge deck where the piers are exposed; ' 'look with zoom first; when the deck is tilted, widen the row band to cover both ends') pr.add_argument("--cols", type=_range, help='search only within this column range, "x0:x1"; default full width') pr.add_argument("--min-gap", type=int, default=20, help="minimum spacing in pixels between two pier columns, default 20") pr.add_argument("--min-prominence", type=float, default=12, help="peak prominence threshold (0-255 gray level), default 12; lower gives extra false peaks, higher misses dense piers at the far end") pr.add_argument("--baseline", type=int, default=61, help="window width of the sliding median baseline, default 61 px; larger than the pier spacing, smaller than the scale of brightness changes along the bridge body") pr.add_argument("--polarity", choices=["auto", "bright", "dark"], default="auto", help="whether piers are brighter or darker than their surroundings, default auto (tries both, takes the one with the larger total prominence)") pr.add_argument("--out", type=Path, required=True, help="output JSON, structure at the top of --help") pr.add_argument("--sheet", type=Path, help="draw the row band and each column (numbered) on the original image, for a person to check") args = ap.parse_args() im = open_photo(args.image, "image").convert("RGB") W, H = im.size if args.cmd == "zoom": zoom(im, args.box, args.scale).save(args.out) print(args.out) return if args.cmd == "piers": r = piers(im, args.rows, args.cols, args.min_gap, args.min_prominence, args.baseline, args.polarity) r = {"image": str(args.image), **r} args.out.parent.mkdir(parents=True, exist_ok=True) args.out.write_text(json.dumps(r, ensure_ascii=False), encoding="utf-8") print(f"rows {r['rows'][0]}:{r['rows'][1]} cols {r['cols'][0]}:{r['cols'][1]} " f"polarity {r['polarity']} found {r['count']} columns") for n, pier in enumerate(r["piers"]): print(f" {n:>2} col {pier['col']:>5} prominence {pier['prominence']:>6.1f} brightness {pier['level']:>6.1f}") print("list: " + ",".join(str(p["col"]) for p in r["piers"])) print("Before feeding geo.py spacing, check --sheet: drop non-pier peaks (bright foreground spots, railings, trees); " "separate structure segments broken by foreground occlusion with ';', e.g. --cols '38,133,218;745,788,829'") print(args.out) if args.sheet: args.sheet.parent.mkdir(parents=True, exist_ok=True) piers_sheet(im, r, args.sheet) print(args.sheet) return args.out_dir.mkdir(parents=True, exist_ok=True) stem = args.image.stem if args.cmd == "variants": stem = args.prefix or (f"{stem}_{'-'.join(map(str, args.box))}" if args.box else stem) outs = [] if args.cmd == "edges": fw, fh = int(W * args.frac), int(H * args.frac) regions = {"top": (0, 0, W, fh), "bottom": (0, H - fh, W, H), "left": (0, 0, fw, H), "right": (W - fw, 0, W, H), "corner_tl": (0, 0, 2 * fw, 2 * fh), "corner_tr": (W - 2 * fw, 0, W, 2 * fh), "corner_bl": (0, H - 2 * fh, 2 * fw, H), "corner_br": (W - 2 * fw, H - 2 * fh, W, H)} for name, box in regions.items(): scale = max(1.0, 1600 / max(box[2] - box[0], box[3] - box[1])) p = args.out_dir / f"{stem}_{name}.jpg" zoom(im, box, scale).save(p, quality=92) outs.append(p) elif args.cmd == "variants": base = im if args.persp: base = perspective(im, [float(x) for x in args.persp.split(",")]) p = args.out_dir / f"{stem}_persp.jpg" base.save(p, quality=95) outs.append(p) if args.box: base = base.crop(args.box) p = args.out_dir / f"{stem}_crop.jpg" base.save(p, quality=95) outs.append(p) for name, img in (("flip", ImageOps.mirror(base)), ("gray", ImageOps.autocontrast(ImageOps.grayscale(base), cutoff=1)), ("wb", gray_world(base))): p = args.out_dir / f"{stem}_{name}.jpg" img.save(p, quality=95) outs.append(p) if max(base.size) < 800: k = 1024 / max(base.size) p = args.out_dir / f"{stem}_up.jpg" base.resize((int(base.width * k), int(base.height * k)), Image.LANCZOS).save(p, quality=95) outs.append(p) elif args.cmd == "grid": tw, th = W / args.cols, H / args.rows ox, oy = tw * args.overlap, th * args.overlap for r in range(args.rows): for c in range(args.cols): box = (int(max(0, c * tw - ox)), int(max(0, r * th - oy)), int(min(W, (c + 1) * tw + ox)), int(min(H, (r + 1) * th + oy))) p = args.out_dir / f"{stem}_r{r}c{c}.jpg" im.crop(box).save(p, quality=92) outs.append(p) for p in outs: print(p) if __name__ == "__main__": # Chinese-locale Windows outputs GBK by default: it crashes on m² or ñ, and any Chinese the agent reads comes out garbled sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace") main() -
intake.py 22.3 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = ["pillow", "pillow-heif"] # /// """Steps 0–3 of geolocation in one command: metadata, zoomed four edges and four corners, search variants, OCR, reverse image search, run in parallel, one report. Outputs (all in --out-dir): exif.json edges/ variants/ ocr.json ocr.png rev/<name>_<engine>.png + .json intake.md intake.json work/<name>.png only when the photo needs it: HEIC/WebP/... decoded, or an EXIF orientation applied. A lossless upright copy with the original's EXIF (orientation reset); every step below then uses it, and intake.json "input" records it. Metadata is always read from the original. Pixel coordinates are in the upright frame either way. Exit status: 0 ok or partial (the report says which steps failed), 1 every enabled visual step (edges, variants, OCR) failed, 2 the photo itself can't be read (metadata is still tried and reported). Steps turned off with --no-* don't count. intake.md is for humans (and the LLM): metadata, OCR text, Baidu similar images (count, source sites, numbered contact sheet rev/<name>_baidu_similar.jpg), tiered vote count of image-search tags, suspected residential compound / housing development / hotel names, list of outputs, items skipped and failed. Always open the image-search screenshots and the similar-image contact sheets; "searched, nothing found" and "not searched" are written separately in the report. intake.py photo.jpg --out-dir intake/ [--box x0,y0,x1,y1 ...] [--engines baidu,yandex] [--exclude word1,word2] [--no-rev] [--no-ocr] [--max-variants 4] Examples: intake.py photo.jpg --out-dir intake/ intake.py photo.jpg --out-dir intake/ --box 300,120,900,760 --exclude 网络迷踪,<creator name> # in blind tests, exclude walkthrough posts (网络迷踪 = photo geolocation) intake.py photo.jpg --out-dir intake/ --no-rev # only metadata, edge crops, OCR (under 30 seconds) """ from __future__ import annotations import argparse from _image import ImageReadError, write_working_copy from _net import PROXY_HELP import json import os import re import subprocess import sys import time from collections import Counter from concurrent.futures import ThreadPoolExecutor from pathlib import Path HERE = Path(__file__).parent # uv run writes its own path into the UV env var: call child scripts with it, so uv is found even when it isn't on PATH (e.g. just installed, terminal not reopened) UV = os.environ.get("UV") or "uv" CITY_SUFFIX = ("省", "市", "区", "县", "州", "盟", "旗", "国", "府", "道", "自治区", "特别行政区", "City", "Province", "County", "Prefecture") PLACE_WORDS = ("公园", "大厦", "小区", "花园", "广场", "学校", "中学", "小学", "大学", "酒店", "宾馆", "景区", "寺", "塔", "桥", "大楼", "中心", "村", "镇", "街", "路", "湖", "山", "站", "港", "码头", "厂", "园", "苑", "府", "城", "馆", "庙", "教堂", "Park", "Tower", "Hotel", "Bridge", "Station", "Square", "Plaza", "Church", "Temple", "Mall", "Street", "Road", "Avenue") KNOWN_CITIES = ("北京", "上海", "天津", "重庆", "广州", "深圳", "成都", "杭州", "武汉", "西安", "南京", "苏州", "郑州", "长沙", "青岛", "沈阳", "大连", "厦门", "福州", "济南", "合肥", "昆明", "贵阳", "南宁", "哈尔滨", "长春", "石家庄", "太原", "兰州", "乌鲁木齐", "拉萨", "呼和浩特", "银川", "西宁", "海口", "三亚", "宁波", "无锡", "东莞", "佛山", "珠海", "香港", "澳门", "台北", "东京", "大阪", "首尔", "曼谷", "新加坡", "吉隆坡", "伦敦", "巴黎", "纽约", "洛杉矶", "悉尼", "墨尔本", "温哥华", "多伦多", "莫斯科", "柏林", "罗马", "马德里") def _run(cmd: list[str], cwd: Path | None = None, timeout: int = 900) -> tuple[int, str, str]: try: # Child scripts and this one both use UTF-8: Chinese Windows reads and writes GBK by default, and if the two sides differ you get mojibake or crashes r = subprocess.run(cmd, text=True, encoding="utf-8", errors="replace", capture_output=True, cwd=cwd, timeout=timeout, env={**os.environ, "PYTHONUTF8": "1"}) return r.returncode, r.stdout, r.stderr except subprocess.TimeoutExpired: return 124, "", f"timed out after {timeout}s" def _script(name: str) -> str: return str(HERE / name) GENERIC = ("城市街道", "城市", "街道", "市区", "街景", "建筑", "楼房", "高楼", "夜景", "风景", "天空", "道路", "马路", "小镇", "乡村", "都市", "city", "street", "town", "building", "skyline", "road", "urban") KNOWN_CITIES_EN = ("hong kong", "kowloon", "tokyo", "osaka", "kyoto", "seoul", "bangkok", "singapore", "kuala lumpur", "taipei", "shanghai", "beijing", "shenzhen", "guangzhou", "chengdu", "chongqing", "london", "paris", "new york", "los angeles", "sydney", "melbourne", "vancouver", "toronto", "moscow", "berlin", "rome", "madrid", "dubai", "istanbul", "mumbai", "delhi", "hanoi", "ho chi minh", "manila", "jakarta", "macau", "lisbon", "barcelona", "amsterdam", "prague", "vienna", "cairo") def _classify(tag: str) -> str: t = tag.strip(":: ,,。.") low = t.lower() if low in GENERIC or t in GENERIC: return "other" if any(c in low for c in KNOWN_CITIES_EN): return "city" if any(c in t for c in KNOWN_CITIES): return "city" if len(t) >= 3 and any(t.endswith(s) for s in CITY_SUFFIX) and not any(g in t for g in ("街道", "城市")): return "city" if any(w in t for w in PLACE_WORDS): return "place" return "other" def _collect_rev(rev_dir: Path) -> tuple[list[dict], dict]: """Read revimg's .json files: guess/links for each image and engine, and do the tiered vote count.""" entries = [] for jf in sorted(rev_dir.glob("*.json")): try: d = json.loads(jf.read_text(encoding="utf-8")) except Exception: # noqa: BLE001 continue m = re.match(r"(.+)_(baidu|yandex|bing|baiduimg|sogouimg)$", jf.stem) if not m: continue simi = d.get("similar") or [] entries.append({"variant": m.group(1), "engine": m.group(2), "guess": d.get("guess") or [], "links": (d.get("links") or [])[:8], "error": d.get("error"), "refused": bool(d.get("refused")), "shot": str(jf.with_suffix(".png").name), "json": jf.name, "similar_n": len(simi), "similar_sites": Counter(x.get("site") or "?" for x in simi).most_common(), "similar_sheet": Path(d["similar_sheet"]).name if d.get("similar_sheet") else None, "similar_note": d.get("similar_sheet_note")}) votes: dict = {"city": {}, "place": {}} for e in entries: seen_city = set() for g in e["guess"]: body = re.sub(r"^(图中可能是|Image appears to contain)[::]?", "", g).strip() for tag in re.split(r"[、/,,;;|]", body): tag = tag.strip() if len(tag) < 2: continue k = _classify(tag) if k == "city": key = tag if (e["engine"], key) in seen_city: # several variants from one engine naming the same city count as one vote continue seen_city.add((e["engine"], key)) votes["city"].setdefault(key, set()).add(e["engine"]) elif k == "place": votes["place"].setdefault(tag, set()).add(f"{e['engine']}/{e['variant']}") return entries, {k: {t: sorted(v) for t, v in d.items()} for k, d in votes.items()} def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("photo") ap.add_argument("--out-dir", type=Path, required=True) ap.add_argument("--box", action="append", help="x0,y0,x1,y1: tight-crop variant, repeatable") ap.add_argument("--engines", default="baidu,yandex") ap.add_argument("--exclude", help="words to exclude from reverse image search results (for blind tests)") ap.add_argument("--no-rev", action="store_true") ap.add_argument("--no-ocr", action="store_true") ap.add_argument("--max-variants", type=int, default=4, help="max number of variants each engine searches (besides the original)") ap.add_argument("--proxy", default=os.environ.get("GEO_PROXY"), help=PROXY_HELP) args = ap.parse_args() photo = Path(args.photo).resolve() out = args.out_dir out.mkdir(parents=True, exist_ok=True) (out / "edges").mkdir(exist_ok=True) (out / "variants").mkdir(exist_ok=True) t_all = time.time() timings: dict = {} status: dict = {} def timed(name, fn): t0 = time.time() try: res = fn() status[name] = "ok" if res[0] == 0 else f"failed: {(res[2] or res[1])[-300:].strip()}" except Exception as e: # noqa: BLE001 status[name] = f"exception: {e}" res = (1, "", str(e)) timings[name] = round(time.time() - t0, 1) return res # A photo the later steps or the search engines can't take as stored gets one upright lossless working copy try: work = write_working_copy(photo, out / "work" / f"{photo.stem}.png") except ImageReadError as e: _unreadable_input(photo, out, e, timed) return src = Path(work["working"]) if work else photo def exif(): return _run([UV, "run", _script("exif.py"), str(photo)]) def edges(): return _run([UV, "run", _script("imgprep.py"), "edges", str(src), "--out-dir", str(out / "edges")]) def variants(): rc, so, se = _run([UV, "run", _script("imgprep.py"), "variants", str(src), "--prefix", "full", "--out-dir", str(out / "variants")]) for i, b in enumerate(args.box or [], 1): r2 = _run([UV, "run", _script("imgprep.py"), "variants", str(src), "--box", b, "--prefix", f"box{i}", "--out-dir", str(out / "variants")]) rc, so, se = max(rc, r2[0]), so + r2[1], se + r2[2] return rc, so, se def ocr(): return _run([UV, "run", _script("ocr.py"), str(src), "--out", str(out / "ocr.json"), "--draw", str(out / "ocr.png")]) # Batch 1: metadata, edges, variants, OCR in parallel with ThreadPoolExecutor(4) as ex: f_exif = ex.submit(timed, "exif", exif) f_edges = ex.submit(timed, "edges", edges) f_var = ex.submit(timed, "variants", variants) f_ocr = None if args.no_ocr else ex.submit(timed, "ocr", ocr) exif_res = f_exif.result() f_edges.result() f_var.result() if f_ocr: f_ocr.result() try: exif_json = json.loads(exif_res[1].strip().splitlines()[0]) if exif_res[1].strip() else {} except Exception: # noqa: BLE001 exif_json = {"raw": exif_res[1][:500]} (out / "exif.json").write_text(json.dumps(exif_json, ensure_ascii=False, indent=1), encoding="utf-8") # Batch 2: reverse image search, two engines in parallel, each searches the original + up to max-variants variants rev_dir = out / "rev" entries, votes = [], {"city": {}, "place": {}} if not args.no_rev: rev_dir.mkdir(exist_ok=True) var_files = sorted((out / "variants").glob("*.jpg")) + sorted((out / "variants").glob("*.png")) prefer = [f for f in var_files if any(k in f.stem for k in ("crop", "flip", "box"))] + [f for f in var_files if not any(k in f.stem for k in ("crop", "flip", "box"))] images = [str(src)] + [str(f) for f in prefer[: args.max_variants]] def rev(engine): cmd = [UV, "run", _script("revimg.py"), *images, "--out-dir", str(rev_dir), "--engines", engine] if args.exclude: cmd += ["--exclude", args.exclude] if args.proxy: cmd += ["--proxy", args.proxy] return _run(cmd, timeout=900) with ThreadPoolExecutor(2) as ex: futs = {eng: ex.submit(timed, f"rev-{eng}", (lambda e=eng: (lambda: rev(e)))()) for eng in [e for e in args.engines.split(",") if e]} for eng, f in futs.items(): f.result() entries, votes = _collect_rev(rev_dir) ocr_items = [] if (out / "ocr.json").exists(): try: ocr_items = json.loads((out / "ocr.json").read_text(encoding="utf-8")).get("items", []) except Exception: # noqa: BLE001 pass # ---- report visual = ["edges", "variants"] + ([] if args.no_ocr else ["ocr"]) failed = [k for k, v in status.items() if v != "ok"] result = "failed" if all(status.get(k) != "ok" for k in visual) else "partial" if failed else "ok" L = [f"# Steps 0–3 report: {photo.name}", "", f"Total time {time.time() - t_all:.0f}s; per step: " + ", ".join(f"{k} {v}s" for k, v in timings.items()), ""] if result == "failed": L += [f"**Result: failed.** Every enabled visual step failed ({', '.join(visual)}); reasons under Status. Fix that before going on.", ""] elif result == "partial": L += [f"**Result: partial.** Failed: {', '.join(failed)} (reasons under Status); the other steps' outputs below are usable.", ""] L += ["Next: record clues on board.py (`clue`), lookup clues with `apply`; list the candidates in full first (`children`), then rank.", ""] if work: L += [f"Input: {work['reason']}, so the steps below used `work/{Path(work['working']).name}` ({work['working_size'][0]}x{work['working_size'][1]}, lossless, " "the original's EXIF kept). Metadata was read from the original. Crops and OCR boxes are in this upright frame, the same frame " "every script uses when given the original, so keep passing the original photo to later commands.", ""] L += ["## Metadata", ""] gps = exif_json.get("gps") or exif_json.get("GPS") if exif_json and (gps or exif_json.get("datetime") or exif_json.get("DateTimeOriginal")): L.append("```\n" + json.dumps(exif_json, ensure_ascii=False, indent=1)[:1500] + "\n```") L.append("Metadata is an assumption (it can be edited, it can be stripped): check both GPS and time against the image.") else: L.append("No metadata (common for forwarded images, screenshots, rephotographed images)." + (f" Raw output: `{json.dumps(exif_json, ensure_ascii=False)[:300]}`" if exif_json else "")) L += ["", "## OCR text (second reader; pass=up/tile was only read after zooming, treat it as an assumption)", ""] if args.no_ocr: L.append("Not done (--no-ocr).") elif ocr_items: L.append("| Conf | Pass | Box (px) | Text |") L.append("|---|---|---|---|") for t in ocr_items[:40]: L.append(f"| {t['conf']:.2f} | {t['pass']} | {t['box']} | {t['text']} |") L.append("") L.append("Phone numbers, plates, proper names on road signs, issuing authorities: first `clues.py lookup`, then `board.py apply`.") else: L.append("No text read" + (f" (OCR {status.get('ocr')})" if status.get("ocr") != "ok" else ". Maybe there really is no text, or it's too small: zoom in by hand with imgprep.py zoom and look again")) L += ["", "## Reverse image search", ""] if args.no_rev: L.append("Not done (--no-rev). This is not \"searched, nothing found\".") else: for eng in args.engines.split(","): st = status.get(f"rev-{eng}", "not run") L.append(f"- {eng}: {st}") refused = [e for e in entries if e["error"] or e["refused"]] if refused: L.append("- Items the engine refused or failed (not the same as nothing found): " + ", ".join(f"{e['variant']}/{e['engine']}: {e['error'] or 'refused'}" for e in refused)) L.append("") baidu = sorted([e for e in entries if e["engine"] == "baidu"], key=lambda e: e["variant"] != photo.stem) if baidu: L.append("### Similar images (Baidu; open the contact sheet first)") L.append("") L.append("A near-duplicate photo is the fastest route to a location: the same object or scene has often been photographed by others and posted on Dianping, Douyin, Xiaohongshu, and the source page's shop name, scenic-area name or location tag gives the place directly. " "Open the contact sheet and compare each cell with the query image in the top-left corner on **fixed features** (shapes of structural parts, soot and damage, the mountains/buildings/bridges/pylons behind); merely the same kind of object doesn't count. " "For a near-duplicate: number i is `similar[i]` in that image's JSON, `from` is the source page, `site` is the site; look through the other images on the same source page too. " "For sources that need a login, don't log in and don't bypass verification; instead do a keyword search from image features + site type. Look at the original image's sheet first; look at the variants' sheets only if the original's has no near-duplicate.") L.append("") for e in baidu: head = f"**{e['variant']}**: " if e["error"]: L.append(f"- {head}Baidu error ({e['error'][:80]}), no similar images captured; doesn't count as searched with nothing found") elif not e["similar_n"]: L.append(f"- {head}no similar images captured (Baidu returned none, or the page layout changed); open the screenshot `rev/{e['shot']}` and check for a \"相似图片\" (similar images) section") else: sites = ", ".join(f"{s} {n}" for s, n in e["similar_sites"][:8]) sheet = f"contact sheet `rev/{e['similar_sheet']}` (first {min(24, e['similar_n'])})" if e["similar_sheet"] else "no contact sheet" note = f"; {e['similar_note']}" if e["similar_note"] else "" L.append(f"- {head}{e['similar_n']} images ({sites}); {sheet}{note}; source list in `similar` of `rev/{e['json']}`") L.append("") L.append("### Tiered vote count (several variants from one engine naming the same city count as one vote; conflicting specific places don't cancel city votes)") L.append("") if votes["city"]: for t, engs in sorted(votes["city"].items(), key=lambda kv: -len(kv[1])): L.append(f"- City level **{t}**: {len(engs)} votes ({', '.join(engs)})") else: L.append("- City level: none") if votes["place"]: for t, srcs in sorted(votes["place"].items(), key=lambda kv: -len(kv[1])): L.append(f"- Specific place **{t}**: {len(srcs)} times ({', '.join(srcs)}) → `poi.py \"{t}\" --city <city> --region <cc>` to get coordinates; list every same-name place, then verify") else: L.append("- Specific place level: none") L.append("") L.append("### Each image, each engine") L.append("") for e in entries: L.append(f"**{e['variant']} / {e['engine']}** (screenshot `rev/{e['shot']}`, always open it)") if e["similar_sheet"]: L.append(f"- Similar images: {e['similar_n']}, contact sheet `rev/{e['similar_sheet']}` (see above)") for g in e["guess"]: L.append(f"- Tag: {g[:200]}") for ln in e["links"][:8]: L.append(f"- [{ln.get('site', '')}] {ln.get('title', '')[:80]} — {ln.get('url', '')[:100]}") L.append("") L += ["## Output files", "", f"- Edge crops: `edges/` ({len(list((out / 'edges').glob('*')))} images; look at the four edges and four corners one by one)", f"- Variants: `variants/` ({len(list((out / 'variants').glob('*')))} images)", "- OCR annotated image: `ocr.png`" if not args.no_ocr else "", f"- Image-search screenshots: `rev/` ({len(list(rev_dir.glob('*.png'))) if rev_dir.exists() else 0} images)" if not args.no_rev else "", f"- Baidu similar-image contact sheets: `rev/*_baidu_similar.jpg` ({len(list(rev_dir.glob('*_baidu_similar.jpg'))) if rev_dir.exists() else 0})" if not args.no_rev else "", "", "## Status", ""] for k, v in status.items(): L.append(f"- {k}: {v}") (out / "intake.md").write_text("\n".join(x for x in L if x is not None), encoding="utf-8") (out / "intake.json").write_text(json.dumps({"photo": str(photo), "input": work, "result": result, "exif": exif_json, "ocr": ocr_items, "rev": entries, "votes": votes, "status": status, "timings": timings}, ensure_ascii=False, indent=1), encoding="utf-8") print("\n".join(L[:6])) print(f"-> {out / 'intake.md'} (read this)") if result == "failed": sys.exit(f"intake: every enabled visual step failed ({', '.join(visual)}); see {out / 'intake.md'}") def _unreadable_input(photo: Path, out: Path, err: ImageReadError, timed) -> None: """The photo itself can't be decoded: still try its metadata (a RAW often carries GPS and time), write a short report, exit 2.""" res = timed("exif", lambda: _run([UV, "run", _script("exif.py"), str(photo)])) try: exif_json = json.loads(res[1].strip().splitlines()[0]) if res[1].strip() else {} except Exception: # noqa: BLE001 exif_json = {} (out / "exif.json").write_text(json.dumps(exif_json, ensure_ascii=False, indent=1), encoding="utf-8") L = [f"# Steps 0–3 report: {photo.name}", "", f"**Result: input unreadable.** {err.reason}.", "No crops, variants, OCR or image search were done.", "", "## Metadata", "", "```\n" + json.dumps(exif_json, ensure_ascii=False, indent=1)[:1500] + "\n```" if exif_json else "No metadata could be read either."] (out / "intake.md").write_text("\n".join(L), encoding="utf-8") (out / "intake.json").write_text(json.dumps({"photo": str(photo), "input": {"error": err.category, "reason": err.reason}, "result": "input unreadable", "exif": exif_json}, ensure_ascii=False, indent=1), encoding="utf-8") print("\n".join(L[:4])) print(f"-> {out / 'intake.md'}") sys.exit(2) if __name__ == "__main__": # Chinese Windows outputs GBK by default: it crashes on m², ñ, and Chinese text the agent reads comes out garbled sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace") main() -
match.py 19.8 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = ["pillow", "pillow-heif", "numpy", "torch", "transformers", "opencv-python-headless", "socksio", "pysocks", "requests"] # /// """Similarity ranking of a photo against a batch of candidate real-scene images (street-view renders, satellite thumbnails, reference images): the machine ranks first, you look only at the top few. Uses: - Street-view confirmation: with dozens to hundreds of candidate panorama points, sort by similarity first and open only the top 10 to compare invariant features. - Satellite thumbnails: compare the top-down views of candidate points with the top-down features visible in the photo (weaker than street view; coarse ranking only). - Reference library: compare reference images of bus liveries, streetlight styles, etc. with crops of the photo. rank score and rank the candidates, output ranked.json + contact sheet of the top N index save embeddings of a batch of images to disk (reused repeatedly within the same city) Candidate source, pick one of three: --images <dir or glob> existing images, file name is the id --items <.index.json> --render baidu|gsv index written by baidu_pano.py sheet/sample or gsv.py sheet, rendered item by item --panos panos.json --toward lat,lon | --headings 0,60,… output of baidu_pano.py scan, rendered by heading (can add --within, --spread) Scoring: global descriptor DINOv2 (facebook/dinov2-small, CLS + patch mean) or CLIP (openai/clip-vit-base-patch32) cosine similarity; --refine sift re-ranks the top --refine-top by SIFT + RANSAC inlier count (only inliers ≥ 15 count as geometric consistency). Final order: those with inliers by inlier count, the rest by global score. Scores are only for ordering; whether it is the same place still requires you to compare ≥3 invariant features. On first use the model downloads from HuggingFace and is cached in ~/.cache/huggingface. Examples: match.py rank --query photo.jpg --panos panos.json --toward <lat,lon> --spread 15 --refine sift --top 10 --out ranked.json --sheet ranked.jpg match.py rank --query photo.jpg --query-box 200,100,900,700 --items around.index.json --render baidu --spread-headings -30,0,30 --out r.json --sheet r.jpg match.py rank --query photo.jpg --images cands/ --method clip --out r.json match.py index --images city_panos/ --out city.npz """ from __future__ import annotations import argparse from _device import torch_device from _image import ImageReadError, find_images, open_image, open_photo, upright from _net import PROXY_HELP from _net import model_proxy_env import glob import json import math import os import re import sys import time from concurrent.futures import ThreadPoolExecutor from pathlib import Path import numpy as np from PIL import Image, ImageDraw sys.path.insert(0, str(Path(__file__).parent)) import geo # noqa: E402 MODELS = {"dino": "facebook/dinov2-small", "clip": "openai/clip-vit-base-patch32"} def _proxy_env(proxy: str | None) -> None: model_proxy_env(proxy) IMNET = ([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) CLIPN = ([0.48145466, 0.4578275, 0.40821073], [0.26862954, 0.26130258, 0.27577711]) def _tensor(ims: list[Image.Image], norm: tuple, torch): """PIL 224×224 → (N,3,224,224) normalized tensor. Does its own preprocessing, no torchvision dependency.""" mean, std = (np.array(v, dtype=np.float32).reshape(1, 3, 1, 1) for v in norm) arr = np.stack([np.asarray(im.convert("RGB"), dtype=np.float32) / 255.0 for im in ims]).transpose(0, 3, 1, 2) return torch.from_numpy((arr - mean) / std) def _feat(out): """In transformers 5, get_*_features returns BaseModelOutputWithPooling with the projected vector in pooler_output; older versions return the tensor directly.""" if hasattr(out, "shape"): return out for k in ("pooler_output", "text_embeds", "image_embeds"): v = getattr(out, k, None) if v is not None and hasattr(v, "shape"): return v return out[0] class Embedder: """Global descriptor. dino: DINOv2 CLS + patch mean; clip: image-tower embedding.""" def __init__(self, method: str): import torch from transformers import AutoModel, CLIPModel self.method = method self.dev = torch_device(torch) t0 = time.time() try: self.model = (CLIPModel if method == "clip" else AutoModel).from_pretrained(MODELS[method]).to(self.dev).eval() except Exception as e: # noqa: BLE001 sys.exit(f"failed to load model {MODELS[method]}: {str(e)[:300]}\n" f"check disk space, model availability and `doctor.py --network`.") self.torch = torch print(f"model {MODELS[method]} ready ({self.dev}, {time.time() - t0:.1f}s)", file=sys.stderr) def _views(self, im: Image.Image, multi: bool) -> list[Image.Image]: im = im.convert("RGB") vs = [im.resize((224, 224), Image.BICUBIC)] if multi: w, h = im.size s = min(w, h) vs.append(im.crop(((w - s) // 2, (h - s) // 2, (w + s) // 2, (h + s) // 2)).resize((224, 224), Image.BICUBIC)) # left and right halves: when the street view and the photo viewpoints are offset, the overlapping half matches better vs.append(im.crop((0, 0, int(w * 0.6), h)).resize((224, 224), Image.BICUBIC)) vs.append(im.crop((int(w * 0.4), 0, w, h)).resize((224, 224), Image.BICUBIC)) return vs def embed(self, ims: list[Image.Image], multi: bool = False, batch: int = 32) -> np.ndarray: """Returns (N, V, D): normalized vectors for the V views of each image.""" views = [self._views(im, multi) for im in ims] flat = [v for vs in views for v in vs] out = [] with self.torch.no_grad(): for i in range(0, len(flat), batch): chunk = flat[i:i + batch] if self.method == "clip": x = _tensor(chunk, CLIPN, self.torch).to(self.dev) f = _feat(self.model.get_image_features(pixel_values=x)) else: x = _tensor(chunk, IMNET, self.torch).to(self.dev) h = self.model(pixel_values=x).last_hidden_state f = self.torch.cat([h[:, 0], h[:, 1:].mean(1)], dim=1) f = f / f.norm(dim=1, keepdim=True) out.append(f.float().cpu().numpy()) arr = np.concatenate(out, 0) nv = len(views[0]) if views else 1 return arr.reshape(len(ims), nv, -1) def sift_inliers(a: Image.Image, b: Image.Image, max_side: int = 1024) -> tuple[int, int]: """Inlier count (and match count) from SIFT + ratio test + RANSAC homography.""" import cv2 def prep(im): im = im.convert("L") s = max_side / max(im.size) if s < 1: im = im.resize((int(im.width * s), int(im.height * s)), Image.BICUBIC) return np.array(im) sift = cv2.SIFT_create(nfeatures=3000) ka, da = sift.detectAndCompute(prep(a), None) kb, db = sift.detectAndCompute(prep(b), None) if da is None or db is None or len(ka) < 8 or len(kb) < 8: return 0, 0 m = cv2.BFMatcher().knnMatch(da, db, k=2) good = [x[0] for x in m if len(x) == 2 and x[0].distance < 0.75 * x[1].distance] if len(good) < 8: return 0, len(good) pa = np.float32([ka[g.queryIdx].pt for g in good]) pb = np.float32([kb[g.trainIdx].pt for g in good]) _, mask = cv2.findHomography(pa, pb, cv2.RANSAC, 6.0) return (int(mask.sum()) if mask is not None else 0), len(good) # ---------------------------------------------------------------- candidate sources def _items_from_panos(args) -> list[dict]: import baidu_pano as bp panos = json.loads(Path(args.panos).read_text(encoding="utf-8")) if args.within: wl, wo, wr = (float(v) for v in args.within.split(",")) panos = {k: v for k, v in panos.items() if geo.distance((wl, wo), tuple(v["wgs"])) <= wr} if args.spread: panos = bp.thin(panos, args.spread) target = tuple(float(v) for v in args.toward.split(",")) if args.toward else None items = [] for pid, v in panos.items(): heads = [float(x) for x in args.headings.split(",")] if args.headings else ( [geo.bearing(tuple(v["wgs"]), target) + args.offset] if target else None) if heads is None: sys.exit("--panos needs --toward or --headings") for hd in heads: items.append({"id": pid, "heading": hd % 360, "pitch": args.pitch, "fov": args.fov, "wgs": v["wgs"], "road": v.get("road", ""), "date": v.get("date", "")}) return items def _render_items(items: list[dict], engine: str, proxy: str | None, cache: Path) -> list[Image.Image | None]: if engine == "gsv": import gsv def one(it): try: return gsv.render(it["id"], it["heading"], it.get("pitch", 0), it.get("fov", 90), 640, 480, proxy, cache / "gsv") except Exception: # noqa: BLE001 return None else: import baidu_pano as bp def one(it): try: return bp.render(it["id"], it["heading"], it.get("pitch", 10), it.get("fov", 80), cache=cache / "pano", proxy=proxy) except Exception: # noqa: BLE001 return None with ThreadPoolExecutor(12) as ex: return list(ex.map(one, items)) def _open_images(files: list[Path]) -> tuple[list[Path], list[Image.Image]]: """Decode every candidate up front; skip the files that can't be read as a supported image and say why, instead of failing mid-run.""" ok, ims, bad = [], [], [] for f in files: try: im = upright(open_image(f)) except ImageReadError as e: # group by reason; for decode errors drop the exception detail so one cause stays one line bad.append((f, e.reason.split(" (")[0] if e.category in ("decode-error", "not-image") else e.reason)) continue ok.append(f) ims.append(im) if bad: kinds: dict[str, int] = {} for _, why in bad: kinds[why] = kinds.get(why, 0) + 1 detail = "; ".join(f"{n} {why}" for why, n in sorted(kinds.items(), key=lambda kv: -kv[1])) print(f"skipped {len(bad)} of {len(files)} files that are not readable images: {detail}; e.g. {bad[0][0].name}", file=sys.stderr) if not ims: sys.exit(f"none of the {len(files)} candidate files could be read as an image; see the reasons above") return ok, ims def _load_candidates(args) -> tuple[list[dict], list[Image.Image]]: cache = Path(args.cache) if args.images: p = Path(args.images) files = find_images(p) if p.is_dir() else [Path(x) for x in sorted(glob.glob(args.images))] if not files: sys.exit(f"--images has no images: {args.images}") files, ims = _open_images(files) return [{"id": f.stem, "file": str(f)} for f in files], ims if args.items: items = json.loads(Path(args.items).read_text(encoding="utf-8")) if args.spread_headings: ds = [float(x) for x in args.spread_headings.split(",")] items = [dict(it, heading=(it["heading"] + d) % 360) for it in items for d in ds] elif args.panos: items = _items_from_panos(args) else: sys.exit("candidate source: pick one of --images / --items / --panos") if len(items) > args.max_candidates: if args.toward and all(it.get("wgs") for it in items): tgt = tuple(float(v) for v in args.toward.split(",")) items.sort(key=lambda it: geo.distance(tuple(it["wgs"]), tgt)) how = "nearest to the --toward target first" else: how = "in list order" print(f"{len(items)} candidates, more than --max-candidates {args.max_candidates}; taking {args.max_candidates} {how} (narrow first with --within/--spread)", file=sys.stderr) items = items[: args.max_candidates] engine = args.render or ("gsv" if items and str(items[0].get("id", "")).startswith(("CAoS", "CIHM")) or len(str(items[0].get("id", ""))) == 22 else "baidu") ims = _render_items(items, engine, args.proxy, cache) ok_items, ok_ims = [], [] for it, im in zip(items, ims): if im is not None: ok_items.append(it) ok_ims.append(im) if not ok_ims: sys.exit("not a single candidate rendered: check panorama ids, coverage and service availability with doctor.py --network") if len(ok_ims) < len(items): print(f"{len(items) - len(ok_ims)} failed to render, skipped", file=sys.stderr) return ok_items, ok_ims def _sheet(rows: list[dict], ims: dict, out: Path, cols: int = 3, tw: int = 480, th: int = 360) -> None: from baidu_pano import _font n = len(rows) S = Image.new("RGB", (cols * tw, max(1, (n + cols - 1) // cols) * th), "black") d = ImageDraw.Draw(S) f = _font(16) for i, r in enumerate(rows): x, y = (i % cols) * tw, (i // cols) * th S.paste(ims[r["id_key"]].convert("RGB").resize((tw, th)), (x, y)) t = f"#{r['rank']} inliers {r['inliers'] if r['inliers'] is not None else '-'} global {r['score_global']:.3f} …{str(r['id'])[-9:]}" if r.get("heading") is not None: t += f" h{r['heading']:.0f}" d.rectangle([x, y, x + tw, y + 22], fill="black") d.text((x + 4, y + 2), t, fill="yellow", font=f) S.save(out, quality=88) def cmd_rank(args) -> None: _proxy_env(args.proxy) q = open_photo(args.query, "query") if args.query_box: x0, y0, x1, y1 = (int(float(v)) for v in args.query_box.split(",")) q = q.crop((x0, y0, x1, y1)) items, ims = _load_candidates(args) t0 = time.time() methods = ["dino", "clip"] if args.method == "both" else [args.method] sims = np.zeros(len(ims)) for m in methods: emb = Embedder(m) qf = emb.embed([q], multi=True)[0] # (V, D) cf = emb.embed(ims, multi=False)[:, 0] # (N, D) s = (cf @ qf.T).max(axis=1) # each candidate takes its max similarity over the query's views sims += s / len(methods) order = np.argsort(-sims) rows = [] for rk, i in enumerate(order): it = items[i] rows.append({"rank": rk + 1, "id": it.get("id"), "id_key": i, "file": it.get("file"), "score_global": round(float(sims[i]), 4), "inliers": None, "matches": None, "heading": it.get("heading"), "wgs": it.get("wgs"), "road": it.get("road", ""), "date": it.get("date", ""), "label": it.get("label", "")}) t1 = time.time() if args.refine != "none": top = rows[: args.refine_top] def one(r): return sift_inliers(q, ims[r["id_key"]]) with ThreadPoolExecutor(4) as ex: for r, (inl, mt) in zip(top, ex.map(one, top)): r["inliers"], r["matches"] = inl, mt rows.sort(key=lambda r: (-(r["inliers"] or 0) if (r["inliers"] or 0) >= args.min_inliers else 0, -r["score_global"])) for k, r in enumerate(rows): r["rank"] = k + 1 t2 = time.time() out_rows = rows[: args.top] print(f"{len(ims)} candidates; global scoring {t1 - t0:.1f}s, refinement {t2 - t1:.1f}s") print(f"{'#':>3} {'inliers':>7} {'global':>7} id / heading / position") for r in out_rows: pos = f"{r['wgs'][0]:.5f},{r['wgs'][1]:.5f}" if r.get("wgs") else (r.get("file") or "") print(f"{r['rank']:>3} {(r['inliers'] if r['inliers'] is not None else '-'):>7} {r['score_global']:>7.3f} …{str(r['id'])[-10:]}" f" h{r['heading']:.0f} {pos} {r.get('road', '')}" if r.get("heading") is not None else f"{r['rank']:>3} {(r['inliers'] if r['inliers'] is not None else '-'):>7} {r['score_global']:>7.3f} {r['id']} {pos}") strong = [r for r in out_rows if (r["inliers"] or 0) >= args.min_inliers] if args.refine != "none": print(f"{len(strong)} with inliers ≥{args.min_inliers}" + (": open these first and compare invariant features" if strong else ": this does not mean none of them is right — with a season change, an old capture, or the photo taken on the sidewalk while the street view is from the middle of the road, the ground truth also often has only single-digit inliers. First open the top 10 in --sheet and compare invariant features; if none match, change heading (--spread-headings) or widen the area")) if args.out: Path(args.out).write_text(json.dumps([{k: v for k, v in r.items() if k != "id_key"} for r in rows], ensure_ascii=False, indent=1), encoding="utf-8") print(f"-> {args.out} (all {len(rows)} rows)") if args.sheet: _sheet(out_rows, {r["id_key"]: ims[r["id_key"]] for r in out_rows}, Path(args.sheet)) print(f"-> {args.sheet}") def cmd_index(args) -> None: _proxy_env(args.proxy) p = Path(args.images) files = find_images(p) if p.is_dir() else [Path(x) for x in sorted(glob.glob(args.images))] if not files: sys.exit(f"--images has no images: {args.images}") files, ims = _open_images(files) emb = Embedder(args.method) feats = emb.embed(ims, multi=False)[:, 0] np.savez(args.out, ids=np.array([f.stem for f in files]), files=np.array([str(f) for f in files]), feats=feats, method=args.method) print(f"{len(files)} images -> {args.out}") def _neg_coords(argv: list[str]) -> list[str]: return [" " + a if re.match(r"^-\d[\d.]*(,-?[\d.]+)+$", a) else a for a in argv] def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--proxy", default=os.environ.get("GEO_PROXY"), help=PROXY_HELP) ap.add_argument("--cache", type=Path, default=Path(".geo-cache")) sub = ap.add_subparsers(dest="cmd", required=True) r = sub.add_parser("rank") r.add_argument("--query", required=True) r.add_argument("--query-box", help="x0,y0,x1,y1: compare only this region") r.add_argument("--images") r.add_argument("--items", help=".index.json from baidu_pano.py / gsv.py") r.add_argument("--render", choices=["baidu", "gsv"], help="which engine renders --items; if omitted, guessed from the id format") r.add_argument("--spread-headings", help="extra heading offsets for each --items entry, e.g. -30,0,30") r.add_argument("--panos", help="output of baidu_pano.py scan") r.add_argument("--toward", help="lat,lon") r.add_argument("--headings") r.add_argument("--offset", type=float, default=0) r.add_argument("--within", help="lat,lon,radius in meters") r.add_argument("--spread", type=float, help="thinning spacing in meters") r.add_argument("--pitch", type=float, default=10) r.add_argument("--fov", type=float, default=80) r.add_argument("--max-candidates", type=int, default=400) r.add_argument("--method", choices=["dino", "clip", "both"], default="dino") r.add_argument("--refine", choices=["none", "sift"], default="sift") r.add_argument("--refine-top", type=int, default=30) r.add_argument("--min-inliers", type=int, default=15) r.add_argument("--top", type=int, default=10) r.add_argument("--out") r.add_argument("--sheet") r.add_argument("--proxy", default=argparse.SUPPRESS) r.add_argument("--cache", type=Path, default=argparse.SUPPRESS) i = sub.add_parser("index") i.add_argument("--images", required=True) i.add_argument("--method", choices=["dino", "clip"], default="dino") i.add_argument("--out", required=True) i.add_argument("--proxy", default=argparse.SUPPRESS) args = ap.parse_args(_neg_coords(sys.argv[1:])) if args.cmd == "rank": cmd_rank(args) else: cmd_index(args) if __name__ == "__main__": # Chinese-locale Windows writes GBK by default: m², ñ make it crash, and the Chinese the agent reads comes out garbled sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace") main() -
ocr.py 8.2 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = ["pillow", "pillow-heif", "pyobjc-framework-Vision; sys_platform == 'darwin'", "pyobjc-framework-Quartz; sys_platform == 'darwin'", "rapidocr-onnxruntime"] # /// """Read the text in a photo (second reader): runs once each on the whole image, upscaled, and in tiles; merges and dedupes; marks which text was only readable after zooming. Backends: on macOS, Apple Vision (local, no download, handles Chinese, English, Japanese and Korean); on other systems or when Vision isn't available, RapidOCR. Text read only after upscaling or tiling (pass = up / tile) is just an assumption; go back to the original image and zoom in to take a look. ocr.py photo.jpg [--langs zh-Hans,en] [--upscale 2] [--tiles 2x2] [--min-conf 0.3] [--out ocr.json] [--draw ocr.png] Examples: ocr.py photo.jpg --out ocr.json --draw ocr.png ocr.py photo.jpg --langs zh-Hans,zh-Hant,en,ja --tiles 3x3 --upscale 3 # lots of small distant text: finer tiles, more upscaling """ from __future__ import annotations import argparse from _image import open_photo import json import sys import time from pathlib import Path from PIL import Image, ImageDraw def _iou(a, b) -> float: x0, y0, x1, y1 = max(a[0], b[0]), max(a[1], b[1]), min(a[2], b[2]), min(a[3], b[3]) inter = max(0, x1 - x0) * max(0, y1 - y0) ua = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter return inter / ua if ua > 0 else 0.0 class VisionOCR: name = "apple-vision" def __init__(self, langs: list[str]): import Quartz # noqa: F401 import Vision self.Vision = Vision self.langs = langs def run(self, im: Image.Image) -> list[dict]: import io from Foundation import NSData Vision = self.Vision buf = io.BytesIO() im.convert("RGB").save(buf, format="PNG") data = NSData.dataWithBytes_length_(buf.getvalue(), len(buf.getvalue())) handler = Vision.VNImageRequestHandler.alloc().initWithData_options_(data, None) req = Vision.VNRecognizeTextRequest.alloc().init() req.setRecognitionLevel_(Vision.VNRequestTextRecognitionLevelAccurate) req.setUsesLanguageCorrection_(False) # don't "correct" proper names away try: req.setRecognitionLanguages_(self.langs) except Exception: # noqa: BLE001 pass ok, err = handler.performRequests_error_([req], None) if not ok: raise RuntimeError(f"Vision failed: {err}") W, H = im.size out = [] for obs in req.results() or []: cand = obs.topCandidates_(1) if not cand: continue text = str(cand[0].string()) conf = float(cand[0].confidence()) bb = obs.boundingBox() # normalized, origin at bottom-left x, y, w, h = bb.origin.x, bb.origin.y, bb.size.width, bb.size.height out.append({"text": text, "conf": round(conf, 3), "box": [int(x * W), int((1 - y - h) * H), int((x + w) * W), int((1 - y) * H)]}) return out class RapidOCRBackend: name = "rapidocr" def __init__(self, langs: list[str]): from rapidocr_onnxruntime import RapidOCR self.ocr = RapidOCR() def run(self, im: Image.Image) -> list[dict]: import numpy as np res, _ = self.ocr(np.array(im.convert("RGB"))[:, :, ::-1]) out = [] for box, text, conf in res or []: xs = [p[0] for p in box] ys = [p[1] for p in box] out.append({"text": str(text), "conf": round(float(conf), 3), "box": [int(min(xs)), int(min(ys)), int(max(xs)), int(max(ys))]}) return out def backend(langs: list[str]): if sys.platform == "darwin": try: return VisionOCR(langs) except Exception as e: # noqa: BLE001 print(f"Apple Vision unavailable ({str(e)[:120]}), using RapidOCR instead", file=sys.stderr) try: return RapidOCRBackend(langs) except Exception as e: # noqa: BLE001 sys.exit(f"No OCR backend available: {e}") def recognize(im: Image.Image, be, upscale: float, tiles: tuple[int, int], min_conf: float) -> list[dict]: W, H = im.size found: list[dict] = [] def add(items, pass_name, ox=0, oy=0, scale=1.0): for it in items: if it["conf"] < min_conf or not it["text"].strip(): continue b = it["box"] box = [int(ox + b[0] / scale), int(oy + b[1] / scale), int(ox + b[2] / scale), int(oy + b[3] / scale)] new = {"text": it["text"].strip(), "conf": it["conf"], "box": box, "pass": pass_name} for old in found: if _iou(old["box"], box) > 0.4: if new["conf"] > old["conf"] + 0.05 or (len(new["text"]) > len(old["text"]) and new["conf"] >= old["conf"] - 0.1): old.update(new) break else: found.append(new) add(be.run(im), "full") if upscale and upscale > 1: big = im.resize((int(W * upscale), int(H * upscale)), Image.LANCZOS) add(be.run(big), "up", scale=upscale) r, c = tiles if r * c > 1: tw, th = W / c, H / r for i in range(r): for j in range(c): # Each tile keeps a 10% overlap so text isn't cut at the edges x0, y0 = max(0, int(j * tw - tw * 0.1)), max(0, int(i * th - th * 0.1)) x1, y1 = min(W, int((j + 1) * tw + tw * 0.1)), min(H, int((i + 1) * th + th * 0.1)) s = max(upscale or 1, 2) crop = im.crop((x0, y0, x1, y1)).resize((int((x1 - x0) * s), int((y1 - y0) * s)), Image.LANCZOS) add(be.run(crop), "tile", ox=x0, oy=y0, scale=s) found.sort(key=lambda t: -t["conf"]) return found def _where(box, W, H) -> str: cx, cy = (box[0] + box[2]) / 2 / W, (box[1] + box[3]) / 2 / H v = "top" if cy < 0.33 else ("bottom" if cy > 0.67 else "middle") h = "left" if cx < 0.33 else ("right" if cx > 0.67 else "center") return f"{v}-{h}" if (v, h) != ("middle", "center") else "center" def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("image") ap.add_argument("--langs", default="zh-Hans,zh-Hant,en,ja,ko,fr,es,pt,de,it,ru,th,vi") ap.add_argument("--upscale", type=float, default=2) ap.add_argument("--tiles", default="2x2") ap.add_argument("--min-conf", type=float, default=0.3) ap.add_argument("--out") ap.add_argument("--draw") args = ap.parse_args() im = open_photo(args.image, "image") t0 = time.time() be = backend([x.strip() for x in args.langs.split(",") if x.strip()]) r, c = (int(v) for v in args.tiles.lower().split("x")) found = recognize(im, be, args.upscale, (r, c), args.min_conf) W, H = im.size print(f"{be.name}: {len(found)} items, {time.time() - t0:.1f}s (image {W}×{H})") print(f"{'conf':>5} {'pass':<5} {'where':<13} text") for t in found: print(f"{t['conf']:>5.2f} {t['pass']:<5} {_where(t['box'], W, H):<13} {t['text']}") if any(t["pass"] != "full" for t in found): print("Text with pass=up/tile was only read after zooming; treat it as an assumption: go back to the original, zoom in and look before using it") if args.out: Path(args.out).write_text(json.dumps({"image": args.image, "size": [W, H], "backend": be.name, "items": found}, ensure_ascii=False, indent=1), encoding="utf-8") print(f"-> {args.out}") if args.draw: d = ImageDraw.Draw(im) for t in found: col = "lime" if t["pass"] == "full" else "yellow" d.rectangle(t["box"], outline=col, width=2) d.text((t["box"][0], max(0, t["box"][1] - 12)), f"{t['text'][:20]} {t['conf']:.2f}", fill=col) im.save(args.draw) print(f"-> {args.draw} (green = read from the whole image, yellow = read after upscaling/tiling)") if __name__ == "__main__": # Chinese Windows outputs GBK by default: it crashes on m², ñ, and Chinese text the agent reads comes out garbled sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace") main() -
osm.py 47.7 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = [] # /// """OpenStreetMap Overpass queries: find candidate points by "feature combinations" and "linear corridors" instead of searching the whole map. Suited to photos with "no name, but structure": a power tower next to a high-speed rail bridge, a church at a river bend, a level crossing with four tracks... Data outside China is very complete; in China, roads, rivers, railways, power lines and large buildings are usable, while small shops and compound interiors are basically missing. OSM data in China is incomplete: results can only be a source of candidates, not grounds for exclusion. find find features of one kind within an area near find A that has B within N meters (optionally also C) crossings line-to-point: bridges, dams, ferries, level crossings on a river / railway / road (level crossings estimate track count from node count) route bus / rail / ferry route number → sample points along the route, turning "search the whole city" into "search along one line" intersect crossing points of two kinds of linear features (railway × power line ...), optionally requiring a bend beyond the crossing (angle tower, river bend) street-scan street view geometry template: bearing looking down a street + "building / no building" on each side → candidate intersections for a whole town along take points at a step along any road / river / line (optionally offset to one side of the road), for satellite thumbnails or street view scans of the roadside buildings list large buildings by footprint area (factories, warehouses, farm sheds), marking how sparse the surroundings are coverage how many features of a kind each candidate admin area has: check OSM coverage before enumerating candidates geom export GeoJSON for any filter (keeps line and polygon geometry) for custom analysis raw run your own Overpass QL ({{bbox}} is replaced with s,w,n,e) Run queries one after another: public Overpass servers reject parallel requests from one address (every mirror then fails), and a bbox query is often faster than --area for a whole province. Outputs JSON {name or id: [lat, lon]} (WGS84), which can go straight to tiles.py mark --points to draw on satellite imagery. Examples (江苏省 = Jiangsu Province, 长江 = Yangtze River): osm.py find --bbox 30.24,120.12,30.27,120.17 '["highway"="street_lamp"]' osm.py near --area 江苏省 --a '["railway"="rail"]["highspeed"="yes"]["bridge"]' --b '["power"="tower"]' --within 700 --c '["waterway"="river"]' --within-c 100 osm.py crossings --bbox 31.9,118.4,32.3,119.0 --line '["waterway"="river"]["name"="长江"]' --kind bridge,dam,ferry osm.py crossings --bbox <s,w,n,e> --line '["railway"="rail"]' --kind level_crossing osm.py route --bbox <s,w,n,e> --kind bus --ref <route number> --step 150 --out route.json osm.py intersect --area <full name of province-level admin area> --a '["railway"="rail"]["electrified"="contact_line"]' --b '["power"="line"]' --bend-min 25 --rank-near '["place"~"^(city|town)$"]' osm.py street-scan --bbox <town s,w,n,e> --bearing 320:80 --right building --left empty --out cands.json osm.py geom '["building"]' --bbox 30.25,120.15,30.26,120.16 --out b.geojson osm.py along --bbox <s,w,n,e> --line '["highway"]["ref"="<road number>"]' --step 400 --side north --offset 80 --out pts.json osm.py buildings --bbox <s,w,n,e> --min-area 1500 --sort sparse --out big.json osm.py coverage --areas <district A>,<district B>,<district C> --filter '["leisure"~"^(pitch|track)$"]' osm.py raw query.overpassql --bbox 30.2,120.1,30.3,120.2 """ from __future__ import annotations import argparse from _net import curl_args, PROXY_HELP import hashlib import json import math import os import re import subprocess import sys import time from pathlib import Path # Independent services only: overpass.kumi.systems is the old name of private.coffee (same server), so it can't confirm or replace it ENDPOINTS = [ "https://overpass-api.de/api/interpreter", "https://overpass.private.coffee/api/interpreter", "https://maps.mail.ru/osm/tools/overpass/api/interpreter", ] # overpass-api.de rejects curl's default User-Agent with HTTP 406; public instances ask clients to identify themselves UA = "geo-sleuth (+https://github.com/Oldcircle/geo-sleuth)" AREA_SET = re.compile(r"(\((?:area\[[^;]*\];)+\))->\.(\w+);") def _post(ep: str, ql: str, proxy: str | None, timeout: int) -> tuple[dict | None, str]: """(reply, "") or (None, "HTTP 504: <what the server said>").""" cmd = ["curl", "-q", "-s", "-m", str(timeout + 30), "-A", UA, "-w", "\n%{http_code}", "--data-urlencode", f"data={ql}", ep] cmd += curl_args(proxy) r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace") body, _, code = (r.stdout or "").rpartition("\n") if r.returncode: return None, f"curl exit {r.returncode}" try: data = json.loads(body) except json.JSONDecodeError: text = " ".join(re.sub(r"<[^>]+>", " ", body).split()) return None, f"HTTP {code}: {text[:160]}" return (data, "") if isinstance(data, dict) else (None, f"HTTP {code}: {str(data)[:160]}") def _area_empty(data: dict, ql: str) -> bool: """An --area query that came back empty (or with a zero area count). Mirrors disagree on these: one answered a whole-province power-line query with a clean, empty result while others returned thousands, so an empty answer needs a second mirror.""" if not AREA_SET.search(ql): return False els = data.get("elements") or [] if els and els[0].get("type") == "count": # coverage / _area_count: the first count is the area count return int((els[0].get("tags") or {}).get("total", 0)) == 0 return not els def run(ql: str, proxy: str | None, cache: Path, timeout: int = 180, rounds: int = 3) -> dict: """Try each mirror in turn; when servers are busy (common for public Overpass), wait a while and retry the whole round. Results are cached per query. Error replies are never cached; an empty --area result is cached only when a second mirror agrees.""" cache.mkdir(parents=True, exist_ok=True) key = cache / (hashlib.sha1(ql.encode()).hexdigest()[:16] + ".json") if key.exists(): return json.loads(key.read_text(encoding="utf-8")) errs: dict[str, str] = {} empty_from = None for rnd in range(rounds): for ep in ENDPOINTS: if ep == empty_from: continue data, err = _post(ep, ql, proxy, timeout) if data is None: errs[ep] = err continue remark = data.get("remark") or "" if remark and not data.get("elements"): # runtime error / timeout / out of memory with nothing returned errs[ep] = f"remark: {remark[:160]}" continue if _area_empty(data, ql) and empty_from is None: empty_from, first_empty = ep, data continue if remark: print(f"Overpass remark (results may be incomplete): {remark[:200]}", file=sys.stderr) key.write_text(json.dumps(data), encoding="utf-8") return data if empty_from is not None: print(f"Only {empty_from} answered, with an empty --area result; not confirmed by a second mirror, so not cached. " f"Rerun later, or use --bbox, before treating it as \"none\".", file=sys.stderr) return first_empty last = "; ".join(f"{ep.split('/')[2]} {e}" for ep, e in errs.items()) if rnd < rounds - 1: print(f"No Overpass mirror returned a result, retrying in {15 * (rnd + 1)} s: {last}", file=sys.stderr) time.sleep(15 * (rnd + 1)) sys.exit(f"Overpass query failed on every mirror ({last}). HTTP 504/\"too busy\": retry later, or use --bbox / a smaller area; " f"HTTP 400: check the query syntax; curl exit: network or proxy (doctor.py --network)") def _area_sel(name: str, var: str, loose: bool = False) -> str: """Get OSM areas by name and store them in set var. Chinese ethnic autonomous regions have bilingual names in OSM ("新疆维吾尔自治区 شىنجاڭ…" (Xinjiang), "西藏自治区 བོད་…" (Tibet), Inner Mongolia with Mongolian script), so ["name"="…"] alone silently returns 0 results; that's why name:zh / name:zh-Hans are matched too (indexed, fast). loose=True also adds a prefix regex "name + space + other text" as a fallback for bilingual names without name:zh; it scans all area names, which is slow, so use it only when the lookup above finds nothing.""" q = name.replace("\\", "\\\\").replace('"', '\\"') sel = f'area["name"="{q}"];area["name:zh"="{q}"];area["name:zh-Hans"="{q}"];' if loose: rx = re.sub(r'([.^$*+?()\[\]{}|\\])', r"\\\\\1", name).replace('"', '\\"') sel += f'area["name"~"^{rx} "];' return f"({sel})->.{var};" def _area_count(name: str, args, loose: bool = False) -> int: """How many areas OSM returns for this name (used after a 0-result query to tell "really none" from "wrong name").""" els = run(f"[out:json][timeout:120];{_area_sel(name, 'a', loose)}.a out count;", args.proxy, args.cache).get("elements") or [] return int(((els[0].get("tags") or {}).get("total", 0)) if els else 0) def _scope(args) -> tuple[str, str]: """Returns (preamble statement, filter suffix).""" if args.area: return _area_sel(args.area, "searchArea", getattr(args, "area_loose", False)), "(area.searchArea)" if args.bbox: s, w, n, e = args.bbox return "", f"({s},{w},{n},{e})" sys.exit("Need --bbox or --area") def _center(el: dict) -> list[float] | None: if el["type"] == "node": return [el.get("lat"), el.get("lon")] c = el.get("center") or {} return [c["lat"], c["lon"]] if "lat" in c else None def _points(data: dict) -> dict: out = {} for el in data.get("elements", []): ll = _center(el) if not ll or ll[0] is None: continue name = (el.get("tags") or {}).get("name") or f"{el['type']}/{el['id']}" if name in out: name = f"{name}#{el['id']}" out[name] = [round(ll[0], 6), round(ll[1], 6)] return out def _dist(a, b) -> float: la1, lo1, la2, lo2 = map(math.radians, (*a, *b)) h = math.sin((la2 - la1) / 2) ** 2 + math.cos(la1) * math.cos(la2) * math.sin((lo2 - lo1) / 2) ** 2 return 2 * 6371008.8 * math.asin(math.sqrt(h)) def _cluster(items: list[tuple[str, list[float], dict]], radius: float) -> list[dict]: """Merge results within radius meters into one place (a bridge is often split into several ways; a level crossing has one node per track).""" groups: list[dict] = [] for name, ll, tags in items: for g in groups: if _dist(g["center"], ll) <= radius: g["members"].append((name, ll, tags)) n = len(g["members"]) g["center"] = [(g["center"][0] * (n - 1) + ll[0]) / n, (g["center"][1] * (n - 1) + ll[1]) / n] break else: groups.append({"center": list(ll), "members": [(name, ll, tags)]}) return groups def _sample(elements: list[dict], step: float) -> list[list[float]]: """Take a point every step meters along the lines from out geom.""" pts = [] for el in elements: geom = el.get("geometry") or [] acc = 0.0 for a, b in zip(geom, geom[1:]): pa, pb = (a["lat"], a["lon"]), (b["lat"], b["lon"]) seg = _dist(pa, pb) while seg > 0 and acc <= seg: t = acc / seg pts.append([round(pa[0] + (pb[0] - pa[0]) * t, 6), round(pa[1] + (pb[1] - pa[1]) * t, 6)]) acc += step acc -= seg return pts def cmd_crossings(args) -> dict: pre, sc = _scope(args) kinds = set(args.kind.split(",")) buf = f"{args.buffer:.0f}" parts = [] if "bridge" in kinds: parts.append(f'way["bridge"]["bridge"!="no"](around.L:{buf}){sc};') if "dam" in kinds: parts.append(f'nwr["waterway"~"^(dam|weir)$"](around.L:{buf}){sc};') if "ferry" in kinds: parts.append(f'way["route"="ferry"](around.L:{buf}){sc};') if "level_crossing" in kinds: parts.append(f'node["railway"~"^(level_crossing|crossing)$"](around.L:{buf}){sc};') if not parts: sys.exit("--kind only supports bridge,dam,ferry,level_crossing") ql = f"[out:json][timeout:180];{pre}way{args.line}{sc}->.L;(" + "".join(parts) + ");out center tags;" items = [] for el in run(ql, args.proxy, args.cache).get("elements", []): ll = _center(el) if not ll or ll[0] is None: continue tags = el.get("tags") or {} items.append((tags.get("name") or "", ll, tags)) groups = _cluster(items, 25 if kinds == {"level_crossing"} else 150) print(f"{len(groups)} places (merged from {len(items)} OSM features)") pts = {} for i, g in enumerate(groups, 1): tags = [m[2] for m in g["members"]] names = sorted({m[0] for m in g["members"] if m[0]}) if any(t.get("railway") in ("level_crossing", "crossing") for t in tags): kind = f"level crossing·~{len(g['members'])} tracks" elif any(t.get("waterway") in ("dam", "weir") for t in tags): kind = "dam/weir" elif any(t.get("route") == "ferry" for t in tags): kind = "ferry route" else: use = {("railway" if t.get("railway") else "road" if t.get("highway") else "other") for t in tags} kind = "bridge·" + "/".join(sorted(use)) label = f"{i:02d} {kind} {', '.join(names)[:40]}".strip() pts[label] = [round(g["center"][0], 6), round(g["center"][1], 6)] return pts def cmd_route(args) -> dict: pre, sc = _scope(args) flt = f'["type"="route"]["route"="{args.kind}"]' if args.ref: flt += f'["ref"="{args.ref}"]' if args.name: flt += f'["name"~"{args.name}"]' ql = f"[out:json][timeout:180];{pre}relation{flt}{sc}->.R;.R out tags;way(r.R);out geom;" data = run(ql, args.proxy, args.cache) rels = [e for e in data.get("elements", []) if e["type"] == "relation"] ways = [e for e in data.get("elements", []) if e["type"] == "way"] for rel in rels: t = rel.get("tags") or {} print(f" route {t.get('ref', '')} {t.get('name', '')} {t.get('from', '')}→{t.get('to', '')}") pts = _sample(ways, args.step) print(f"{len(rels)} route relations, {len(ways)} ways; one point every {args.step:.0f} m along the route, {len(pts)} points total") if pts: lats, lons = [p[0] for p in pts], [p[1] for p in pts] print(f" route extent bbox: {min(lats):.4f},{min(lons):.4f},{max(lats):.4f},{max(lons):.4f}") return {f"R{i}": p for i, p in enumerate(pts)} def cmd_intersect(args) -> dict: """Crossing points of two kinds of linear features (railway × power line, river × road ...), optionally requiring B to bend beyond the crossing (angle tower, river bend).""" pre, sc = _scope(args) ql = (f"[out:json][timeout:280];{pre}way{args.a}{sc}->.a;way{args.b}(around.a:30){sc}->.b;" f".a out geom tags;.b out geom tags;") data = run(ql, args.proxy, args.cache, timeout=280) A = [e for e in data.get("elements", []) if e.get("geometry") and _match(e, args.a)] B = [e for e in data.get("elements", []) if e.get("geometry") and e not in A] if not A or not B: print(f"A {len(A)} lines, B {len(B)} lines: no crossing points possible (change the area or tags)") return {} lat0 = A[0]["geometry"][0]["lat"] kx, ky = 111320 * math.cos(math.radians(lat0)), 110540 def pr(g): return (g["lon"] * kx, g["lat"] * ky) cells: dict[tuple[int, int], list] = {} for el in A: pts = [pr(g) for g in el["geometry"]] for s0, s1 in zip(pts, pts[1:]): for c in {(int(s0[0] // 500), int(s0[1] // 500)), (int(s1[0] // 500), int(s1[1] // 500))}: cells.setdefault(c, []).append((s0, s1, el)) def cross(p1, p2, p3, p4): d = (p2[0] - p1[0]) * (p4[1] - p3[1]) - (p2[1] - p1[1]) * (p4[0] - p3[0]) if abs(d) < 1e-9: return None t = ((p3[0] - p1[0]) * (p4[1] - p3[1]) - (p3[1] - p1[1]) * (p4[0] - p3[0])) / d u = ((p3[0] - p1[0]) * (p2[1] - p1[1]) - (p3[1] - p1[1]) * (p2[0] - p1[0])) / d if 0 <= t <= 1 and 0 <= u <= 1: return (p1[0] + t * (p2[0] - p1[0]), p1[1] + t * (p2[1] - p1[1])) return None def find_bend(el, hit): """First bend ≥bend_min in B within bend_within beyond the crossing: returns (distance m, turn angle °) or None.""" if args.bend_min <= 0: return None lo, hi = _band(args.bend_within) pts = [pr(g) for g in el["geometry"]] k = min(range(len(pts)), key=lambda i: math.dist(pts[i], hit)) for direction in (1, -1): d = 0.0 i = k while 0 < i + direction < len(pts) - 1: nxt = i + direction d += math.dist(pts[i], pts[nxt]) i = nxt if d > hi: break if d < lo: continue a1 = math.atan2(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]) a2 = math.atan2(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]) turn = abs(math.degrees(a2 - a1)) turn = min(turn % 360, 360 - turn % 360) if turn >= args.bend_min: return round(d), round(turn) return None hits = [] for el in B: pts = [pr(g) for g in el["geometry"]] for s0, s1 in zip(pts, pts[1:]): for c in {(int(s0[0] // 500), int(s0[1] // 500)), (int(s1[0] // 500), int(s1[1] // 500))}: for a0, a1, ael in cells.get(c, []): x = cross(a0, a1, s0, s1) if not x: continue ta, tb = ael.get("tags") or {}, el.get("tags") or {} lbl = " ".join(filter(None, [ta.get("name", ""), tb.get("name", ""), tb.get("voltage", ""), ta.get("electrified", "")])) hits.append((lbl[:40], [x[1] / ky, x[0] / kx], {"bend": find_bend(el, x)})) groups = _cluster(hits, args.cluster) rows = [] for g in groups: bends = [m[2]["bend"] for m in g["members"] if m[2]["bend"]] rows.append({"label": g["members"][0][0], "ll": [round(g["center"][0], 6), round(g["center"][1], 6)], "bend": min(bends) if bends else None}) if args.ring: c_ll, rmin, rmax = args.ring.split(":") c = tuple(map(float, c_ll.split(","))) before = len(rows) rows = [r for r in rows if float(rmin) <= _dist(c, r["ll"]) <= float(rmax)] print(f"--ring: {len(rows)}/{before} places kept in the ring {rmin}–{rmax} m from {c_ll}") print(f"A {len(A)} lines × B {len(B)} lines → {len(hits)} crossing points, merged into {len(groups)} clusters") if args.bend_min > 0: with_bend = [r for r in rows if r["bend"]] print(f" of these, {len(with_bend)} have a bend ≥{args.bend_min:g}° in B within {args.bend_within} m beyond the crossing" f" (labeled \"bend\"; used only for ranking, see --bend-filter)") if args.bend_filter: dropped = [r for r in rows if not r["bend"]] rows = with_bend print(f" --bend-filter: dropped {len(dropped)} crossing points without a bend. The bend comes from interpreting the photo; " f"if none of the candidates match, look back at this dropped batch first") if args.out and dropped: dp = args.out.with_name(args.out.stem + "_dropped.json") dp.write_text(json.dumps({f"{i:03d} {r['label']}".strip(): r["ll"] for i, r in enumerate(dropped, 1)}, ensure_ascii=False, indent=1), encoding="utf-8") print(f" dropped -> {dp}") if args.rank_near: rows = _rank_rows(rows, args.rank_near, args) elif args.bend_min > 0: rows.sort(key=lambda r: r["bend"] is None) out = {} for i, r in enumerate(rows, 1): extra = [] if r["bend"]: extra.append(f"bend {r['bend'][0]}m/{r['bend'][1]}°") if r.get("near_m") is not None: extra.append(f"{r['near_m'] / 1000:.1f}km from {r['near_name'][:6]}") out[f"{i:03d} {r['label']} {' '.join(extra)}".strip()] = r["ll"] return out _SIDES = {"north": (0.0, 1.0), "south": (0.0, -1.0), "east": (1.0, 0.0), "west": (-1.0, 0.0)} def cmd_along(args) -> dict: """Take a point every step meters along any linear feature (road, river, power line filtered by ref / name), optionally shifting all points to one side of the road. With --bbox, only points inside the box are kept (Overpass returns the whole length of any road that crosses the box). For tiles.py sheet (satellite thumbnails scanning roadside factories, shops, fields) and gsv.py / baidu_pano.py sheet --points (street view scanning storefronts). """ pre, sc = _scope(args) data = run(f"[out:json][timeout:180];{pre}way{args.line}{sc};out geom;", args.proxy, args.cache) ways = [e for e in data.get("elements", []) if e["type"] == "way" and e.get("geometry")] side = _SIDES.get(args.side) if args.side else None pts, seen = {}, [] for w in ways: g = [(n["lat"], n["lon"]) for n in w["geometry"]] acc = 0.0 for a, b in zip(g, g[1:]): seg = _dist(a, b) if seg <= 0: continue kx = 111320 * math.cos(math.radians(a[0])) ux, uy = (b[1] - a[1]) * kx / seg, (b[0] - a[0]) * 110574 / seg # unit vector (east, north components) while acc <= seg: t = acc / seg lat, lon = a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t if side and args.offset: nx, ny = -uy, ux # left normal if nx * side[0] + ny * side[1] < 0: nx, ny = uy, -ux lat += ny * args.offset / 110574 lon += nx * args.offset / kx ll = (round(lat, 6), round(lon, 6)) inside = not args.bbox or (args.bbox[0] <= ll[0] <= args.bbox[2] and args.bbox[1] <= ll[1] <= args.bbox[3]) if inside and not any(_dist(ll, s) < args.step * 0.5 for s in seen[-200:]): seen.append(ll) name = (w.get("tags") or {}).get("ref") or (w.get("tags") or {}).get("name") or f"w{w['id']}" pts[f"{len(pts):04d} {name}"] = list(ll) acc += args.step acc -= seg print(f"{len(ways)} ways, one point every {args.step:.0f} m, {len(pts)} points total" + (f", offset {args.offset:.0f} m to the {args.side} side" if side else "")) if pts: lats, lons = [v[0] for v in pts.values()], [v[1] for v in pts.values()] print(f" extent bbox: {min(lats):.4f},{min(lons):.4f},{max(lats):.4f},{max(lons):.4f}") return pts def _ring_area(coords: list[tuple[float, float]]) -> float: if len(coords) < 3: return 0.0 lat0 = sum(c[0] for c in coords) / len(coords) kx, ky = 111320 * math.cos(math.radians(lat0)), 110574 xy = [(c[1] * kx, c[0] * ky) for c in coords] return abs(sum(x1 * y2 - x2 * y1 for (x1, y1), (x2, y2) in zip(xy, xy[1:] + xy[:1]))) / 2 def cmd_buildings(args) -> dict: """Find buildings with large footprints (factories, warehouses, farm sheds, mine facilities): filter by footprint area and mark how many other buildings are around. In many countries OSM's rural building outlines are traced from satellite imagery, so they have an area even without a name; in China, county and township buildings are often missing, so this can't be grounds for exclusion. "Sparse surroundings" is used only for ranking (--sort sparse), not for filtering: a factory site itself is often split into a pile of small sheds. """ pre, sc = _scope(args) flt = args.filter or '["building"]' data = run(f"[out:json][timeout:240];{pre}way{flt}{sc};out geom;", args.proxy, args.cache) rows = [] for el in data.get("elements", []): g = [(n["lat"], n["lon"]) for n in el.get("geometry") or []] if len(g) < 4: continue area = _ring_area(g[:-1] if g[0] == g[-1] else g) c = (sum(p[0] for p in g) / len(g), sum(p[1] for p in g) / len(g)) rows.append({"id": el["id"], "c": c, "area": area, "tags": el.get("tags") or {}}) big = [r for r in rows if r["area"] >= args.min_area] cells: dict[tuple[int, int], list] = {} cs = args.within / 111000 for r in rows: cells.setdefault((int(r["c"][0] / cs), int(r["c"][1] / cs)), []).append(r) for r in big: ci, cj = int(r["c"][0] / cs), int(r["c"][1] / cs) r["neighbors"] = sum(1 for di in (-1, 0, 1) for dj in (-1, 0, 1) for o in cells.get((ci + di, cj + dj), []) if o is not r and _dist(r["c"], o["c"]) <= args.within) big.sort(key=(lambda r: (r["neighbors"], -r["area"])) if args.sort == "sparse" else (lambda r: -r["area"])) print(f"{len(rows)} buildings, {len(big)} with footprint ≥{args.min_area:.0f} m² (sorted by {'sparse surroundings' if args.sort == 'sparse' else 'area'}; nb=number of other buildings within {args.within:.0f} m)") pts = {} for i, r in enumerate(big, 1): t = r["tags"] kind = t.get("building", "") label = f"{i:03d} {int(r['area'])}m² nb{r['neighbors']} {kind if kind != 'yes' else ''} {t.get('name', '')}" pts[" ".join(label.split())] = [round(r["c"][0], 6), round(r["c"][1], 6)] return pts def cmd_coverage(args) -> dict: """How many features of a kind each candidate admin area has. Run it before enumerating candidates with OSM: districts with clearly low counts can't be excluded on OSM results and must be scanned separately with a satellite imagery grid.""" rows = [] for name in [a for a in args.areas.split(",") if a]: for loose in (False, True): # bilingual names without name:zh: the second round falls back to the name prefix ql = f'[out:json][timeout:120];{_area_sel(name, "a", loose)}.a out count;nwr{args.filter}(area.a);out count;' els = run(ql, args.proxy, args.cache).get("elements") or [] counts = [int((e.get("tags") or {}).get("total", 0)) for e in els] if counts and counts[0] > 0: break rows.append((name, counts[1] if len(counts) > 1 and counts[0] > 0 else -1)) top = max((n for _, n in rows), default=0) print(f"Count of {args.filter} in OSM (compare only admin areas of the same level; a low count may be truly low, or just unmapped):") for name, n in rows: if n < 0: flag = " ← no admin area with this name in OSM; try another spelling (with or without the \"区/县/市\" suffix)" elif n < max(5, top * 0.25): flag = " ← low: OSM results can't exclude this area; scan it with tiles.py sheet --grid instead" else: flag = "" print(f" {name}: {'?' if n < 0 else n}{flag}") return {} def _rank_rows(rows: list[dict], flt: str, args) -> list[dict]: """Rank candidates by "distance to the nearest feature of a kind (town, service area, station ...)", nearest first. Ranks only, never deletes.""" pre, sc = _scope(args) if args.bbox: # towns just outside the bbox edge count too s, w, n, e = args.bbox sc = f"({s - 0.2},{w - 0.2},{n + 0.2},{e + 0.2})" data = run(f"[out:json][timeout:180];{pre}nwr{flt}{sc};out center tags;", args.proxy, args.cache) refs = [] for el in data.get("elements", []): ll = _center(el) if ll and ll[0] is not None: refs.append(((el.get("tags") or {}).get("name", "?"), ll)) if not refs: print(f"--rank-near {flt} found no features; not ranking") return rows for r in rows: name, ll = min(refs, key=lambda t: _dist(r["ll"], t[1])) r["near_m"], r["near_name"] = _dist(r["ll"], ll), name rows.sort(key=lambda r: r["near_m"]) print(f" ranked by distance to the nearest {flt} ({len(refs)} found), nearest first") return rows def _match(el: dict, flt: str) -> bool: """Roughly check whether a feature matches A's tag filter (looks only at key=value conditions).""" tags = el.get("tags") or {} for k, v in re.findall(r'\["([^"]+)"="([^"]+)"\]', flt): if tags.get(k) != v: return False for k in re.findall(r'\["([^"=\]]+)"\]', flt): if k not in tags: return False return True def cmd_geom(args) -> dict: """Any filter → GeoJSON (keeps geometry for lines, polygons and points), for custom analysis or overlays.""" pre, sc = _scope(args) data = run(f"[out:json][timeout:180];{pre}nwr{args.filter}{sc};out geom tags;", args.proxy, args.cache) feats = [] for el in data.get("elements", []): tags = el.get("tags") or {} if el["type"] == "node": geom = {"type": "Point", "coordinates": [el["lon"], el["lat"]]} elif el.get("geometry"): coords = [[g["lon"], g["lat"]] for g in el["geometry"]] closed = len(coords) > 3 and coords[0] == coords[-1] geom = {"type": "Polygon", "coordinates": [coords]} if closed and ("building" in tags or "area" in tags) \ else {"type": "LineString", "coordinates": coords} else: continue feats.append({"type": "Feature", "id": f"{el['type']}/{el['id']}", "properties": tags, "geometry": geom}) gj = {"type": "FeatureCollection", "features": feats} out = args.out or Path("osm_geom.geojson") out.write_text(json.dumps(gj, ensure_ascii=False), encoding="utf-8") if not feats and args.area and not getattr(args, "area_loose", False) and _area_count(args.area, args) == 0: if _area_count(args.area, args, loose=True) > 0: print(f"\"{args.area}\" has a bilingual name in OSM and no name:zh; retrying by name prefix", file=sys.stderr) args.area_loose = True return cmd_geom(args) print(f"Note: no area named \"{args.area}\" found in OSM; 0 results doesn't mean there is nothing here; try another spelling or use --bbox", file=sys.stderr) print(f"{len(feats)} features -> {out}") args.out = None return {} def _band(s: str) -> tuple[float, float]: a, b = s.split(":") return float(a), float(b) def cmd_street_scan(args) -> dict: """Street view geometry template: camera standing at an intersection (or on a road), looking down a street, with a "building / no building" pattern on each side → candidate points across the whole town.""" if not args.bbox: sys.exit("street-scan only supports --bbox (the extent of one town, sides no longer than ~15 km)") s, w, n, e = args.bbox if (n - s) > 0.2 or (e - w) > 0.25: print("Tip: the area is large and the query may time out; running it town by town in several passes is more reliable", file=sys.stderr) excl = "footway|path|cycleway|steps|track|pedestrian|bridleway|corridor|platform|proposed|construction|elevator" ql = (f'[out:json][timeout:280];way["highway"]["highway"!~"^({excl})$"]({s},{w},{n},{e});out body geom;' f'way["building"]({s},{w},{n},{e});out body geom;') data = run(ql, args.proxy, args.cache, timeout=280) lat0, lon0 = (s + n) / 2, (w + e) / 2 kx, ky = 111320 * math.cos(math.radians(lat0)), 110540 def P(g): return ((g["lon"] - lon0) * kx, (g["lat"] - lat0) * ky) types = set(args.types.split(",")) ways = [el for el in data.get("elements", []) if el["type"] == "way" and el.get("geometry")] hw = [el for el in ways if "highway" in (el.get("tags") or {})] bl = [el for el in ways if "building" in (el.get("tags") or {})] node_ways: dict[int, set] = {} for wy in hw: for nd in wy["nodes"]: node_ways.setdefault(nd, set()).add(wy["id"]) grid: dict[tuple[int, int], list] = {} area: dict[int, float] = {} for b in bl: pts = [P(g) for g in b["geometry"]] area[b["id"]] = abs(sum(x1 * y2 - x2 * y1 for (x1, y1), (x2, y2) in zip(pts, pts[1:]))) / 2 if len(pts) > 2 else 0 for (x1, y1), (x2, y2) in zip(pts, pts[1:]): k = max(1, int(math.hypot(x2 - x1, y2 - y1) / 2)) for i in range(k + 1): x, y = x1 + (x2 - x1) * i / k, y1 + (y2 - y1) * i / k grid.setdefault((int(x // 50), int(y // 50)), []).append((x, y, b["id"])) b_lo, b_hi = _band(args.bearing) near_lo, near_hi = _band(args.band) clr_lo, clr_hi = _band(args.clear) ah_lo, ah_hi = _band(args.ahead) reach = int(max(ah_hi, near_hi, clr_hi) // 50) + 2 def bearing_ok(brg): return (b_lo <= brg <= b_hi) if b_lo <= b_hi else (brg >= b_lo or brg <= b_hi) def along(pts, dist): acc = 0.0 for (x1, y1), (x2, y2) in zip(pts, pts[1:]): L = math.hypot(x2 - x1, y2 - y1) if L > 0 and acc + L >= dist: f = (dist - acc) / L return (x1 + (x2 - x1) * f, y1 + (y2 - y1) * f) acc += L return None def side_ok(J, ux, uy, want, sign): if want == "any": return True nx, ny = uy * sign, -ux * sign # sign=+1 right side, -1 left side cx, cy = int(J[0] // 50), int(J[1] // 50) hits = set() blocked = False for gx in range(cx - reach, cx + reach + 1): for gy in range(cy - reach, cy + reach + 1): for x, y, bid in grid.get((gx, gy), []): t = (x - J[0]) * ux + (y - J[1]) * uy lat_d = (x - J[0]) * nx + (y - J[1]) * ny if ah_lo <= t <= ah_hi: if near_lo <= lat_d <= near_hi and area.get(bid, 0) >= args.min_area: hits.add(bid) if clr_lo <= lat_d <= clr_hi: blocked = True return bool(hits) if want == "building" else not blocked cands = {} for wy in hw: if wy["tags"]["highway"] not in types: continue g = [P(x) for x in wy["geometry"]] starts = [] for end in (0, -1): if args.anywhere or len(node_ways.get(wy["nodes"][end], ())) >= 2: starts.append((g if end == 0 else g[::-1], "s" if end == 0 else "e", 0.0)) if args.anywhere: total = sum(math.hypot(x2 - x1, y2 - y1) for (x1, y1), (x2, y2) in zip(g, g[1:])) d = args.every while d < total - args.look: starts.append((g, f"m{int(d)}", d)) d += args.every for pts, tag, off in starts: J = along(pts, off) if off else pts[0] p_far, p_mid = along(pts, off + args.look), along(pts, off + args.look / 2) if not J or not p_far or not p_mid: continue ux, uy = p_far[0] - J[0], p_far[1] - J[1] L = math.hypot(ux, uy) ux, uy = ux / L, uy / L brg = math.degrees(math.atan2(ux, uy)) % 360 if not bearing_ok(brg): continue b_mid = math.degrees(math.atan2(p_mid[0] - J[0], p_mid[1] - J[1])) % 360 if abs((b_mid - brg + 180) % 360 - 180) > 15: # the first half must be straight enough continue if side_ok(J, ux, uy, args.right, +1) and side_ok(J, ux, uy, args.left, -1): name = (wy.get("tags") or {}).get("name", "") key = f"{len(cands) + 1:03d} {name[:20]} heading {brg:.0f}° way{wy['id']}{tag}".strip() cands[key] = [round(J[1] / ky + lat0, 6), round(J[0] / kx + lon0, 6)] print(f"{len(hw)} roads, {len(bl)} buildings → {len(cands)} candidates (point = camera position at the intersection / on the road)") return cands def _neg_coords(argv: list[str]) -> list[str]: """argparse treats negative coordinates like -1.45,-48.5 as option names; a leading space makes them plain values (float ignores the space). Every southern- or western-hemisphere case needs this.""" return [" " + a if re.match(r"^-\d[\d.]*(,-?[\d.]+)+$", a) else a for a in argv] def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--proxy", default=os.environ.get("GEO_PROXY"), help=PROXY_HELP) ap.add_argument("--cache", type=Path, default=Path(".geo-cache/osm")) sub = ap.add_subparsers(dest="cmd", required=True) def scope(sp): sp.add_argument("--proxy", default=argparse.SUPPRESS, help="can also go after the subcommand") sp.add_argument("--cache", type=Path, default=argparse.SUPPRESS) sp.add_argument("--bbox", type=lambda s: tuple(map(float, s.split(","))), help="south,west,north,east") sp.add_argument("--area", help='OSM admin area name, e.g. "Bayern", "江苏省" (Jiangsu Province), "深圳市" (Shenzhen)') sp.add_argument("--out", type=Path, help="write {name:[lat,lon]}") sp.add_argument("--limit", type=int, default=30, help="max rows printed to the terminal") f = sub.add_parser("find") f.add_argument("filter", help='Overpass tag filter, e.g. \'["amenity"="fuel"]\'') scope(f) n = sub.add_parser("near") n.add_argument("--a", required=True, help="the main feature to find") n.add_argument("--b", required=True, help="feature that must be near the main feature") n.add_argument("--within", type=float, default=200, help="max distance from A to B (meters)") n.add_argument("--c", help="optional: a third required feature") n.add_argument("--within-c", type=float, default=200) n.add_argument("--report", type=Path, help="write the actual B/C distances and key tags for each A (JSON), for ranking") n.add_argument("--rank-near", help='rank by distance to the nearest feature of a kind, e.g. \'["place"~"^(city|town)$"]\' (ranks only, never deletes)') scope(n) c = sub.add_parser("crossings") c.add_argument("--line", required=True, help='linear feature tags, e.g. \'["waterway"="river"]["name"="长江"]\' (Yangtze), \'["railway"="rail"]\'') c.add_argument("--kind", default="bridge,dam,ferry", help="bridge,dam,ferry,level_crossing, comma-separated") c.add_argument("--buffer", type=float, default=40, help="how many meters from the linear feature still counts as on the line") scope(c) ro = sub.add_parser("route") ro.add_argument("--kind", default="bus", help="bus / trolleybus / tram / subway / train / light_rail / ferry") ro.add_argument("--ref", help="route number, e.g. 79") ro.add_argument("--name", help="regex fragment of the route name") ro.add_argument("--step", type=float, default=200, help="take a point every this many meters along the route") scope(ro) r = sub.add_parser("raw") r.add_argument("file", type=Path, help="Overpass QL file") scope(r) it = sub.add_parser("intersect") it.add_argument("--a", required=True, help='first kind of line, e.g. \'["railway"="rail"]["electrified"="contact_line"]\'') it.add_argument("--b", required=True, help='second kind of line, e.g. \'["power"="line"]\'') it.add_argument("--bend-min", type=float, default=0, help="mark crossing points where B bends ≥ this many degrees beyond the crossing (angle tower / river bend) and rank them first; 0 = ignore bends. By default only marks, never deletes") it.add_argument("--bend-within", default="100:1000", help="distance range of the bend from the crossing, m") it.add_argument("--bend-filter", action="store_true", help="actually delete crossing points without a bend (deleted ones go to <out>_dropped.json). Use only when the bend is a fact you confirmed with your own eyes in the photo") it.add_argument("--rank-near", help='rank by distance to the nearest feature of a kind, e.g. \'["place"~"^(city|town)$"]\', \'["highway"="services"]\'') it.add_argument("--ring", help="lat,lon:min_m:max_m — keep only crossing points in this distance ring around a point (e.g. a distance range computed from a landmark's pixel size)") it.add_argument("--cluster", type=float, default=100, help="crossing points within this many meters merge into one place") scope(it) cv = sub.add_parser("coverage", help="how many features of a kind each candidate admin area has: check OSM coverage before enumerating; blank areas can't be grounds for exclusion") cv.add_argument("--areas", required=True, help='comma-separated OSM admin area names, e.g. "<name>区,<name>县"') cv.add_argument("--filter", required=True, help='feature filter, e.g. \'["leisure"~"^(pitch|track)$"]\', \'["building"]\'') cv.add_argument("--proxy", default=argparse.SUPPRESS) cv.add_argument("--cache", type=Path, default=argparse.SUPPRESS) al = sub.add_parser("along", help="take points at a step along a road / river / power line (optionally offset to one side), for satellite thumbnails or street view scans of the roadside") al.add_argument("--line", required=True, help='linear feature filter, e.g. \'["highway"]["ref"="<road number>"]\', \'["highway"]["name"~"<road name fragment>"]\'') al.add_argument("--step", type=float, default=150, help="take a point every this many meters; ≤150 for finding storefronts in street view, 300–500 is fine for satellite thumbnails") al.add_argument("--side", choices=list(_SIDES), help="which side of the road to offset to (by compass direction)") al.add_argument("--offset", type=float, default=0, help="offset in meters; when looking at factories on one side of the road, use the building-to-road distance") scope(al) bu = sub.add_parser("buildings", help="find large buildings by footprint area (factories, warehouses, farm sheds), marking how sparse the surroundings are") bu.add_argument("--min-area", type=float, default=1000, help="minimum footprint m²") bu.add_argument("--within", type=float, default=200, help="radius for counting surrounding buildings, m") bu.add_argument("--sort", choices=["area", "sparse"], default="area", help="sparse: fewer surrounding buildings first (isolated rural factories); ranks only, never deletes") bu.add_argument("--filter", help='building filter, default \'["building"]\', e.g. \'["building"~"industrial|warehouse|farm_auxiliary"]\'') scope(bu) gm = sub.add_parser("geom") gm.add_argument("filter", help='tag filter, e.g. \'["building"]\', \'["highway"]\'') scope(gm) ss = sub.add_parser("street-scan") ss.add_argument("--bearing", required=True, help="bearing range of the street the camera looks down, e.g. 320:80 (may cross north)") ss.add_argument("--right", choices=["building", "empty", "any"], default="any", help="right side of the street") ss.add_argument("--left", choices=["building", "empty", "any"], default="any", help="left side of the street") ss.add_argument("--band", default="4:22", help="'building' test band: lateral distance from the street centerline, m") ss.add_argument("--clear", default="3:10", help="'empty' test band: no building may be in this lateral range, m") ss.add_argument("--ahead", default="0:40", help="how far ahead along the street to look, m") ss.add_argument("--min-area", type=float, default=150, help="minimum footprint m² required for 'building'") ss.add_argument("--look", type=float, default=60, help="how many meters ahead are used to compute the street bearing") ss.add_argument("--types", default="residential,unclassified,living_street,tertiary,secondary,service") ss.add_argument("--anywhere", action="store_true", help="the camera isn't necessarily at an intersection: try every --every meters along the street") ss.add_argument("--every", type=float, default=30) scope(ss) args = ap.parse_args(_neg_coords(sys.argv[1:])) if args.cmd == "crossings": pts = cmd_crossings(args) elif args.cmd == "route": pts = cmd_route(args) elif args.cmd == "intersect": pts = cmd_intersect(args) elif args.cmd == "geom": pts = cmd_geom(args) elif args.cmd == "coverage": cmd_coverage(args) return elif args.cmd == "along": pts = cmd_along(args) elif args.cmd == "buildings": pts = cmd_buildings(args) elif args.cmd == "street-scan": pts = cmd_street_scan(args) else: if args.cmd == "raw": ql = args.file.read_text(encoding="utf-8") if args.bbox: ql = ql.replace("{{bbox}}", ",".join(map(str, args.bbox))) else: pre, sc = _scope(args) if args.cmd == "find": ql = f"[out:json][timeout:180];{pre}nwr{args.filter}{sc};out center tags;" else: ql = f"[out:json][timeout:180];{pre}nwr{args.b}{sc}->.b;nwr{args.a}(around.b:{args.within:.0f}){sc}->.a;" if args.c: ql += f"nwr{args.c}{sc}->.c;nwr.a(around.c:{args.within_c:.0f})->.a;" ql += ".a out center tags;" pts = _points(run(ql, args.proxy, args.cache)) print(f"{len(pts)} results") if not pts: print(" 0 doesn't mean none: OSM may not have mapped this area at all (common for counties and townships in China), so it can't be grounds for exclusion; compare first with osm.py coverage, or use tiles.py sheet --grid instead") if args.cmd == "near" and args.rank_near and pts: rows = _rank_rows([{"label": k, "ll": v, "bend": None} for k, v in pts.items()], args.rank_near, args) pts = {f"{r['label']} {r['near_m'] / 1000:.1f}km from {r['near_name'][:6]}" if r.get("near_m") is not None else r["label"]: r["ll"] for r in rows} if args.cmd == "near" and args.report and pts: pre2, sc2 = _scope(args) rep = {} a_ids = f"nwr{args.b}{sc2}->.b;nwr{args.a}(around.b:{args.within:.0f}){sc2}->.a;" if args.c: a_ids += f"nwr{args.c}{sc2}->.c;nwr.a(around.c:{args.within_c:.0f})->.a;" q2 = (f"[out:json][timeout:200];{pre2}{a_ids}.a out center tags;" f"nwr{args.b}(around.a:{args.within:.0f}){sc2};out geom tags;") if args.c: q2 += f"nwr{args.c}(around.a:{args.within_c:.0f}){sc2};out geom tags;" d2 = run(q2, args.proxy, args.cache) others = [e for e in d2.get("elements", []) if e.get("geometry") or e["type"] == "node"] for name, ll in pts.items(): near_list = [] for el in others: geom = el.get("geometry") or ([{"lat": el["lat"], "lon": el["lon"]}] if el["type"] == "node" else []) if not geom: continue dmin = min(_dist(ll, (g["lat"], g["lon"])) for g in geom) t = el.get("tags") or {} keep = {k2: v for k2, v in t.items() if k2 in ("name", "voltage", "electrified", "highspeed", "railway", "power", "waterway", "highway", "tracks")} near_list.append({"dist_m": round(dmin), "tags": keep}) near_list.sort(key=lambda x: x["dist_m"]) rep[name] = {"ll": ll, "nearest": near_list[:4]} args.report.write_text(json.dumps(rep, ensure_ascii=False, indent=1), encoding="utf-8") print(f"report -> {args.report}") for k, (name, ll) in enumerate(pts.items()): if k >= args.limit: print(" …") break print(f" {name} {ll[0]},{ll[1]}") if args.out: args.out.write_text(json.dumps(pts, ensure_ascii=False, indent=1), encoding="utf-8") print(f"-> {args.out}") if __name__ == "__main__": # Chinese Windows outputs GBK by default: it crashes on m² or ñ, and the Chinese the agent reads is garbled sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace") main() -
poi.py 10.7 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = [] # /// """Place name, residential compound name, housing development name, shop name → coordinate candidates (no API key). Reverse image search often returns "the photo may be XX花园 (XX Garden)" or "XX大厦 (XX Tower)"; a compound name or hotel name read on a web page must be placed at coordinates before it can be checked. The same name often exists dozens of times across China; the script lists the same-name points in every region, and other clues in the photo pick among them. Sources: so 360 Maps search: good coverage of residential compounds, housing developments, shops and organizations, with address; raw coordinates are GCJ-02, converted to WGS84 osm OpenStreetMap Nominatim: named compounds, parks, roads; coordinates WGS84 sug Baidu Maps search suggestions: only "city + district + name", no coordinates; use it to see which districts in China have this name Examples: poi.py "<compound name>" --city <city> # all same-name points in the city, outputs {name: [lat, lon]} poi.py "<district> <road name> 学校" --city <municipality or prefecture-level city> # --city only accepts prefecture-level cities; put the district in the keyword (学校 = school) poi.py "<compound name>" --city <city> --region cn # sources from the region pack (CN: 360 Maps + OSM + Baidu suggestions) poi.py "<street address or place name>" --region mx # packs without a poi service: OSM restricted to that country poi.py "<compound name>" # no city: lists which cities in China have a same-name point poi.py "<hotel name>" --city <city> --out pois.json && tiles.py sheet --points pois.json --zoom 18 --out pois_sheet.jpg Baidu Maps web place search requires a CAPTCHA, so the script doesn't use it; the Tencent and Amap APIs require a key, so it doesn't use them either. """ from __future__ import annotations import argparse from _net import curl_args, PROXY_HELP import json import os import re import subprocess import sys import urllib.parse from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) import geo # noqa: E402 import regions # noqa: E402 UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36" def _curl(url: str, proxy: str | None = None, ua: str = UA, timeout: int = 25) -> str: cmd = ["curl", "-q", "-sS", "-m", str(timeout), "-A", ua, url] cmd += curl_args(proxy) # Pages are UTF-8. Without encoding=, Chinese Windows decodes as GBK, and stdout is None when decoding fails r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace") if r.returncode != 0: print(f"Request failed (curl exit code {r.returncode}): {r.stderr.strip()[:200]}", file=sys.stderr) return r.stdout def search_so(kw: str, city: str | None, n: int, proxy: str | None = None) -> tuple[list[dict], list[dict]]: q = {"keyword": kw, "batch": 1, "number": n, "ext": 1, "sid": 1000} if city: q["cityname"] = city raw = _curl("https://restapi.map.so.com/newapi?" + urllib.parse.urlencode(q), proxy=proxy) try: d = json.loads(raw) except json.JSONDecodeError: print(f"360 Maps did not return JSON: {raw[:120]}", file=sys.stderr) return [], [] rows = [] for p in d.get("poi") or []: if not p.get("x") or not p.get("y"): continue lat, lon = geo.gcj2wgs(float(p["y"]), float(p["x"])) rows.append({"src": "so", "name": p.get("name", ""), "city": p.get("city", ""), "area": p.get("area", ""), "address": p.get("address", ""), "type": p.get("cat_new_name") or p.get("type", ""), "wgs": [round(lat, 6), round(lon, 6)]}) cities = [{"city": c.get("name"), "province": c.get("province"), "count": c.get("resultnum")} for c in d.get("citysuggestion") or []] return rows, cities def search_osm(kw: str, city: str | None, n: int, proxy: str | None, country: str) -> list[dict]: q = {"q": f"{kw} {city}" if city else kw, "format": "jsonv2", "limit": n, "accept-language": "en"} if country: q["countrycodes"] = country raw = _curl("https://nominatim.openstreetmap.org/search?" + urllib.parse.urlencode(q), proxy=proxy, ua="geo-sleuth/1.0 (photo geolocation research)") try: d = json.loads(raw) except json.JSONDecodeError: print(f"Nominatim did not return JSON (check service availability with doctor.py --network): {raw[:120]}", file=sys.stderr) return [] return [{"src": "osm", "name": p.get("name") or p.get("display_name", "").split(",")[0], "city": "", "area": "", "address": p.get("display_name", ""), "type": f"{p.get('category')}/{p.get('type')}", "wgs": [round(float(p["lat"]), 6), round(float(p["lon"]), 6)]} for p in d] def search_sug(kw: str, proxy: str | None = None) -> list[dict]: raw = _curl("https://map.baidu.com/su?" + urllib.parse.urlencode({"wd": kw, "cid": 1, "type": 0, "newmap": 1, "ie": "utf-8"}), proxy=proxy) try: d = json.loads(raw) except json.JSONDecodeError: return [] out = [] for s in d.get("s") or []: parts = s.split("$") if len(parts) >= 4: out.append({"src": "sug", "city": parts[0], "area": parts[1], "name": parts[3]}) return out def _neg_coords(argv: list[str]) -> list[str]: """argparse treats negative coordinates like -1.45,-48.5 as option names; a leading space makes them plain values (float ignores the space). Every southern- or western-hemisphere case needs this.""" return [" " + a if re.match(r"^-\d[\d.]*(,-?[\d.]+)+$", a) else a for a in argv] def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("keyword", help="place name, residential compound name, housing development name, or shop name") ap.add_argument("--city", help="city name, e.g. <name>市 or <name> (omit to search the whole country)") ap.add_argument("--region", help="region pack (code or name): sources and Nominatim country come from its services.poi") ap.add_argument("--sources", help="any of so,osm,sug, comma-separated (default: the region pack's; without one, so,osm,sug for Han-script keywords, else osm)") ap.add_argument("--limit", type=int, default=10, help="max results per source") ap.add_argument("--country", help="Nominatim country code (default: the region pack's; without one, no restriction)") ap.add_argument("--proxy", default=os.environ.get("GEO_PROXY"), help=PROXY_HELP) ap.add_argument("--out", type=Path, help="write {name: [lat, lon]} (WGS84) for tiles.py mark / sheet") args = ap.parse_args(_neg_coords(sys.argv[1:])) svc = {} if args.region: pk = regions.resolve(args.region) if pk: svc = (pk.get("services") or {}).get("poi") or {"sources": "osm", "country": pk["code"].lower()} elif re.fullmatch(r"[A-Za-z]{2}", args.region.strip()): # No pack for this country: still restrict Nominatim to it rather than searching the whole world svc = {"sources": "osm", "country": args.region.strip().lower()} print(f"No region pack for '{args.region}': OSM only, restricted to countrycodes={svc['country']}", file=sys.stderr) else: sys.exit(f"No region pack for '{args.region}' (installed: {', '.join(sorted(regions.packs()))}): " f"pass its ISO 3166-1 alpha-2 code (e.g. --region in) so the search stays inside that country") han = re.search(r"[\u4e00-\u9fff]", args.keyword) args.sources = args.sources or svc.get("sources") or ("so,osm,sug" if han and not args.region else "osm") args.country = args.country if args.country is not None else svc.get("country", "") src = set(args.sources.split(",")) rows: list[dict] = [] if "so" in src: so_rows, cities = search_so(args.keyword, args.city, args.limit, args.proxy) if args.city and so_rows and not any(args.city in (r["city"] + r["area"] + r["address"]) for r in so_rows): print(f"Note: none of the 360 Maps results is in \"{args.city}\" — it doesn't recognize this city name and silently switched to another city. " f"Pass a prefecture-level city or municipality to --city (e.g. 重庆 (Chongqing)), and put the district name in the keyword (e.g. \"<district> <name>\")") rows += so_rows if cities and not args.city: print("360 Maps: cities in China with same-name results (result count)") print(" " + ", ".join(f"{c['city']}({c['count']})" for c in cities[:30])) if "osm" in src: rows += search_osm(args.keyword, args.city, args.limit, args.proxy, args.country) if "sug" in src: sug = search_sug(args.keyword, args.proxy) if sug: print("Baidu suggestions (no coordinates; only shows which districts have this name):") print(" " + "; ".join(f"{s['city']}{s['area']} {s['name']}" for s in sug[:15])) # Deduplicate when two sources return the same place (within 150 m and one name contains the other) uniq: list[dict] = [] for r in rows: if any(geo.distance(r["wgs"], u["wgs"]) < 150 and (r["name"] in u["name"] or u["name"] in r["name"]) for u in uniq): continue uniq.append(r) print(f"\n{len(uniq)} candidates with coordinates (WGS84):") pts = {} for i, r in enumerate(uniq, 1): label = f"{i:02d} {r['name']} {r['area']}".strip() pts[label] = r["wgs"] print(f" {label} {r['wgs'][0]},{r['wgs'][1]} [{r['src']}] {r['type']} {r['address'][:60]}") if not uniq: print(" None. Try another spelling (drop the \"小区/花园\" (compound/garden) suffix, add the district name), or use revimg.py --query to search the web for an address") if args.out: args.out.write_text(json.dumps(pts, ensure_ascii=False, indent=1), encoding="utf-8") print(f"-> {args.out}") if len(uniq) >= 2: # Several campuses of one school or several branches of a chain all show up here; the first one is not necessarily the right one print(f"\nAll {len(uniq)} places must be candidates; don't take only the first one: " + (f"`board.py add --from {args.out} --level area --parent <parent>` puts them all on the candidate board" if args.out else "add --out pois.json, then `board.py add --from pois.json --level area` puts them all on the candidate board")) if __name__ == "__main__": # Chinese Windows outputs GBK by default: it crashes on m² or ñ, and the Chinese the agent reads is garbled sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace") main() -
pose.py 25.9 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = ["pillow", "pillow-heif", "numpy"] # /// """Solve the camera position (camera resection): ≥4 points in the photo whose locations you can identify → camera lat/lon, height, heading, pitch, roll, field of view + error radius. Suited to window views, high-rise downward shots, views across a river and other photos where "you can pick out several points on satellite imagery". Compared with a two-sight-line intersection it also uses height and pitch information, and can work out the floor. Level shots (standing on the ground photographing a distant tower, bridge, pier) also work: fix height and give the eye height. solve solve the camera position; the output includes a per-point check (leave_one_out): drop one point at a time, re-solve, and see how far off the other points' prediction of it is check score discrete candidate camera positions: use when several places all fit and you must pick one; each candidate solves only the heading, then is rescored with each point dropped in turn; only robust Δchi2 > 9 (dropping any one point can't rescue it) counts as inconsistent with the photo project with a known camera position, project a set of lat/lon points onto the photo (check whether riverbanks, roads, buildings line up); with --horizon <sea horizon row> it validates pitch: the target's depression angle is not the camera's pitch; a sea horizon at the frame center means pitch≈0 spec.json format: { "frame": "upright", "image_size": [1476, 827], "points": [ {"name": "bridgehead", "px": [212, 431], "ll": [lat, lon], "h": 470}, {"name": "spire", "px": [1180, 120], "ll": [lat, lon], "h": 620} ], "init": {"at": [lat, lon], "height": 560, "heading": 190, "pitch": -8, "hfov": 65}, "fix": ["hfov"] } - frame: "upright" for new specs measured on the photo as a viewer shows it (EXIF orientation applied), matching imgprep.py and ocr.py. "stored" uses the unrotated stored pixels. Specs without this key keep the legacy "stored" frame when solved again. The solved pose.json keeps the frame in _frame, and `project` draws in it; a pose.json without the key also uses "stored". - px and image_size: pixel coordinates and full photo size in the declared frame (top-left is 0,0). - h: height of the point, **on the same datum as the camera height**. Using elevation above sea level throughout is the most reliable: ground points via `terrain.py elev`, rooftop = ground elevation + building height. - init: rough camera position and heading; if unknown, give the center of the candidate area, and --restarts will start from multiple points within --search-radius. - fix: parameters that can be fixed (hfov, roll, height); if you know the focal length, fix hfov; more stable with few points. Examples: pose.py solve spec.json --photo photo.jpg --out pose.png --search-radius 500 pose.py check spec.json --cands cands.json # cands.json: {"candA": [lat, lon], "candB": [lat, lon, eye_elevation]} pose.py project --pose pose.json --points river.json --photo photo.jpg --out check.png """ from __future__ import annotations import argparse from _image import frame_of, open_photo import json import math import re import sys from pathlib import Path import numpy as np from PIL import Image, ImageDraw NAMES = ["east_m", "north_m", "height", "heading", "pitch", "roll", "focal_px"] def _frame(lat0: float): return 111320.0 * math.cos(math.radians(lat0)), 110540.0 def project(params: np.ndarray, pts_enu: np.ndarray, W: int, H: int) -> tuple[np.ndarray, np.ndarray]: e, n, u, yaw, pitch, roll, f = params ps, ts, rs = map(math.radians, (yaw, pitch, roll)) fwd = np.array([math.sin(ps) * math.cos(ts), math.cos(ps) * math.cos(ts), math.sin(ts)]) r0 = np.array([math.cos(ps), -math.sin(ps), 0.0]) u0 = np.cross(r0, fwd) right = r0 * math.cos(rs) + u0 * math.sin(rs) up = -r0 * math.sin(rs) + u0 * math.cos(rs) v = pts_enu - np.array([e, n, u]) z = v @ fwd x, y = v @ right, v @ up zz = np.where(z > 1e-3, z, np.nan) return np.stack([W / 2 + f * x / zz, H / 2 - f * y / zz], axis=1), z def residuals(p, pts, obs, W, H): uv, z = project(p, pts, W, H) r = (uv - obs).ravel() return np.where(np.isnan(r), 5000.0, r) def lm(p0, free, pts, obs, W, H, iters=200): p = p0.astype(float).copy() steps = np.array([0.5, 0.5, 0.5, 0.01, 0.01, 0.01, 1.0]) lam = 1e-2 r = residuals(p, pts, obs, W, H) cost = float(r @ r) J = None for _ in range(iters): J = np.zeros((r.size, len(free))) for j, k in enumerate(free): dp = p.copy() dp[k] += steps[k] J[:, j] = (residuals(dp, pts, obs, W, H) - r) / steps[k] A = J.T @ J g = J.T @ r improved = False for _ in range(10): try: delta = -np.linalg.solve(A + lam * np.diag(np.diag(A) + 1e-9), g) except np.linalg.LinAlgError: lam *= 10 continue cand = p.copy() cand[free] += delta rc = residuals(cand, pts, obs, W, H) cc = float(rc @ rc) if cc < cost: p, r, cost, lam, improved = cand, rc, cc, max(lam / 3, 1e-7), True break lam *= 4 if not improved or float(np.abs(delta).max()) < 1e-4: break return p, cost, J def solve(spec: dict, search_radius: float, restarts: int, seed: int = 7, pt_sigma: float = 5.0) -> dict: W, H = spec["image_size"] init = spec.get("init", {}) lat0, lon0 = init["at"] kx, ky = _frame(lat0) pts = np.array([[(q["ll"][1] - lon0) * kx, (q["ll"][0] - lat0) * ky, q.get("h", 0.0)] for q in spec["points"]]) obs = np.array([q["px"] for q in spec["points"]], dtype=float) hfov = init.get("hfov", 65) f0 = (W / 2) / math.tan(math.radians(hfov / 2)) fixed = set(spec.get("fix", [])) free = [i for i, name in enumerate(NAMES) if not ((name == "focal_px" and "hfov" in fixed) or (name == "roll" and "roll" in fixed) or (name == "height" and "height" in fixed))] n_obs, k = obs.size, len(free) if n_obs < k: raise SystemExit(f"Too few points: {len(obs)} points give only {n_obs} equations for {k} unknowns. Give at least {math.ceil(k / 2)} points, or fix hfov/roll with fix") base = np.array([0.0, 0.0, init.get("height", 30.0), init.get("heading", 0.0), init.get("pitch", 0.0), 0.0, f0]) rng = np.random.default_rng(seed) best = None for t in range(max(1, restarts)): p0 = base.copy() if t: ang = rng.uniform(0, 2 * math.pi) rad = search_radius * math.sqrt(rng.uniform()) p0[0], p0[1] = rad * math.sin(ang), rad * math.cos(ang) if "height" not in fixed: p0[2] = max(1.0, base[2] * rng.uniform(0.5, 1.5)) p0[3] = (base[3] + rng.uniform(-90, 90)) % 360 if "heading" in init else rng.uniform(0, 360) p0[4] = base[4] + rng.uniform(-15, 15) p, cost, J = lm(p0, free, pts, obs, W, H) if best is None or cost < best[1]: best = (p, cost, J) p, cost, J = best dof = n_obs - k rms = math.sqrt(cost / len(obs)) out = {"camera_ll": [round(lat0 + p[1] / ky, 7), round(lon0 + p[0] / kx, 7)], "height": round(p[2], 1), "heading": round(p[3] % 360, 2), "pitch": round(p[4], 2), "roll": round(p[5], 2), "hfov": round(2 * math.degrees(math.atan((W / 2) / p[6])), 2), "rms_px": round(rms, 2), "points": len(obs), "unknowns": k, "dof": dof} uv, _ = project(p, pts, W, H) out["residuals_px"] = {q.get("name", str(i)): [round(float(a), 1), round(float(b), 1)] for i, (q, (a, b)) in enumerate(zip(spec["points"], uv - obs))} if dof > 0 and J is not None: sigma2 = cost / dof try: cov = sigma2 * np.linalg.inv(J.T @ J) idx = {v: i for i, v in enumerate(free)} se = [math.sqrt(max(cov[idx[i], idx[i]], 0)) if i in idx else 0.0 for i in range(len(NAMES))] out["sigma"] = {"east_m": round(se[0], 1), "north_m": round(se[1], 1), "height_m": round(se[2], 1), "heading_deg": round(se[3], 2)} out["radius_px_only_m"] = round(3 * math.hypot(se[0], se[1]), 1) # 3σ from pixel noise only; doesn't cover the ground truth, don't use it directly except np.linalg.LinAlgError: out["sigma"] = "singular: the points are too concentrated (all on one line or in one direction); add points in different directions and at different distances" else: out["sigma"] = "zero degrees of freedom, can't estimate the error; add more points" out["_params"] = [float(x) for x in p] out["_frame"] = {"lat0": lat0, "lon0": lon0, "image_size": [W, H], "frame": frame_of(spec)} mc = mc_radius(p, free, pts, obs, W, H, pt_sigma, rms) out["radius_m"] = mc["radius_m"] out["radius_note"] = (f"95% radius, Monte Carlo {mc['trials']} trials: control-point coordinates perturbed by ±{pt_sigma:g} m" f" (--pt-sigma), pixels by ±{mc['px_sigma_used']:g} px. " f"Median offset {mc['p50_m']:g} m. The old pixel-noise-only measure is in radius_px_only_m; it doesn't cover the ground truth.") out["pt_sigma_m"] = pt_sigma out["leave_one_out"] = leave_one_out(p, free, pts, obs, W, H, spec["points"]) return out def mc_radius(p, free, pts, obs, W, H, pt_sigma: float, rms_px: float, trials: int = 60, seed: int = 11, px_sigma: float | None = None) -> dict: """Control-point coordinate error doesn't propagate into the reprojection residuals: shift all points the same way, the camera shifts along, and the residuals don't change at all. So the inv(JtJ) covariance is blind in that direction: in tests, with 5 m coordinate noise the 3σ radius covers the ground truth only 67% of the time (should be 97%). Here the control points are perturbed by pt_sigma and the pixels by rms, re-solved from the converged solution, and the 95th percentile of the position offset is taken as the radius. The pixel path doesn't subtract the coordinate-error contribution: the slight double-counting happens to make up for the underestimate from "re-solving from the converged solution can't reach large offsets". Calibration in tests: the version that subtracts it on the pixel path covers 91%, without subtracting 95%; validated on three uncalibrated geometries, 286/300 = 95%.""" rng = np.random.default_rng(seed) if px_sigma is None: px_sigma = max(rms_px, 1.0) offs = [] for _ in range(trials): pts_j = pts.copy() if pt_sigma > 0: pts_j[:, :2] += rng.normal(0, pt_sigma, (len(pts_j), 2)) q, _, _ = lm(p.copy(), free, pts_j, obs + rng.normal(0, px_sigma, obs.shape), W, H, iters=60) offs.append(math.hypot(q[0] - p[0], q[1] - p[1])) return {"radius_m": round(float(np.percentile(offs, 95)), 1), "p50_m": round(float(np.percentile(offs, 50)), 1), "q": {k: round(float(np.percentile(offs, k)), 1) for k in (50, 90, 95, 99)}, "px_sigma_used": round(px_sigma, 1), "trials": trials} def leave_one_out(p, free, pts, obs, W, H, spec_pts) -> dict: """Drop one point at a time and re-solve: where the other points "predict" this point, how many pixels off, and how far the camera position moved. For a misidentified point (wrong feature, wrong coordinates, inconsistent height datum), the remaining points fit very well once it is dropped, but it is itself far off.""" if 2 * (len(obs) - 1) < len(free) + 1: return {"note": f"with {len(obs)} points, dropping one leaves no more equations than unknowns, so the per-point check can't be done; add another point, or fix height/roll"} rows, errs = {}, [] for i in range(len(obs)): keep = [j for j in range(len(obs)) if j != i] q, cost, _ = lm(p, free, pts[keep], obs[keep], W, H) uv, _ = project(q, pts[i:i + 1], W, H) e = float(np.hypot(*(uv[0] - obs[i]))) if not np.isnan(uv[0]).any() else float("inf") rest = math.sqrt(cost / len(keep)) rows[spec_pts[i].get("name", str(i))] = {"pred_err_px": round(e, 1), "rest_rms_px": round(rest, 1), "shift_m": round(math.hypot(q[0] - p[0], q[1] - p[1]), 1)} errs.append((e, rest, i)) # Flag only one: dropping it gives the lowest rms for the remaining points, clearly below the other drops (0.4× the median), and its own prediction error is more than 3× the remaining rms. # Measured on synthetic cases (one point injected with a 40–80 m shift): downward shot, 7 points: false positives 0–2/20, detected 12–13/20; level shot, 6 points: false positives 2–5/20, detected 6–17/20 (unstable). dof_after = 2 * (len(obs) - 1) - len(free) e, rest, i = min(errs, key=lambda t: t[1]) med = float(np.median([t[1] for t in errs])) flag = [spec_pts[i].get("name", str(i))] if (dof_after >= 2 and e > 3 * max(rest, 2.0) and rest < 0.4 * med) else [] return {"points": rows, "suspect": flag, "most_improved": {"name": spec_pts[i].get("name", str(i)), "rest_rms_px": round(rest, 1), "median_rest_rms_px": round(med, 1)}, "rule": "flag only one: dropping it gives the lowest remaining rms (< 0.4× the median of the other drops) and its prediction error > 3× the remaining rms; " "unreliable for level shots and few points; nothing flagged doesn't mean there is no misidentified point; most_improved is listed whether or not it passes the threshold, check it first"} def _fit_at(e, n, h, pts, obs, W, H, free, f0, pitch0, p_start=None): """Camera position fixed at (e, n, h); solve only heading/pitch/roll (and the focal length if not fixed). With p_start, start from it and don't try multiple starts.""" starts = [p_start] if p_start is not None else [np.array([e, n, h, hd, pt, 0.0, f0]) for hd in range(0, 360, 30) for pt in (pitch0 - 10, pitch0, pitch0 + 10)] best = None for p0 in starts: p, cost, _ = lm(np.array(p0, dtype=float), free, pts, obs, W, H) if abs(p[4]) > 89 or abs(p[5]) > 60: # flipped solutions (camera facing backward, upside down) don't count continue if best is None or cost < best[1]: best = (p, cost) return best if best else (np.array(starts[0], dtype=float), float("inf")) def check(spec: dict, cands: dict, px_sigma: float) -> dict: """Score discrete candidate camera positions: each candidate frees only heading, pitch, roll (and the focal length if not fixed); compare reprojection errors. Suited to cases where "two or three places fit geometrically and you must pick one", e.g. the viewing platform marked on the map vs another stretch of shore on satellite imagery, or several buildings on the same alignment line. A single misidentified point can flip the ranking (synthetic cases: in downward shots, ground truth ranked first dropped from 15/15 to 1/15), so each point is also dropped in turn and rescored: robust_delta_chi2 takes the smallest over all drops; only candidates where that is also large count as inconsistent.""" W, H = spec["image_size"] init = spec.get("init", {}) lat0, lon0 = init["at"] kx, ky = _frame(lat0) pts = np.array([[(q["ll"][1] - lon0) * kx, (q["ll"][0] - lat0) * ky, q.get("h", 0.0)] for q in spec["points"]]) obs = np.array([q["px"] for q in spec["points"]], dtype=float) names = [q.get("name", str(i)) for i, q in enumerate(spec["points"])] fixed = set(spec.get("fix", [])) f0 = (W / 2) / math.tan(math.radians(init.get("hfov", 65) / 2)) free = [3, 4, 5] + ([] if "hfov" in fixed else [6]) subsets = [("all_points", list(range(len(obs))))] if 2 * (len(obs) - 1) >= len(free) + 1: subsets += [(f"drop {names[i]}", [j for j in range(len(obs)) if j != i]) for i in range(len(obs))] res, costs = {}, {} for name, c in cands.items(): ll, h = (c[:2], c[2]) if len(c) > 2 else (c, init.get("height", 30.0)) e, n = (ll[1] - lon0) * kx, (ll[0] - lat0) * ky p, cost = _fit_at(e, n, h, pts, obs, W, H, free, f0, init.get("pitch", 0.0)) costs[name] = {"all_points": cost} for sname, keep in subsets[1:]: costs[name][sname] = _fit_at(e, n, h, pts[keep], obs[keep], W, H, free, f0, 0, p_start=p)[1] uv, _ = project(p, pts, W, H) res[name] = {"rms_px": round(math.sqrt(cost / len(obs)), 1), "heading": round(p[3] % 360, 1), "pitch": round(p[4], 1), "roll": round(p[5], 1), "hfov": round(2 * math.degrees(math.atan((W / 2) / p[6])), 1), "residuals_px": {nm: [round(float(a), 1), round(float(b), 1)] for nm, (a, b) in zip(names, uv - obs)}} for sname, keep in subsets: best = min(costs[c][sname] for c in cands) sig2 = max(px_sigma ** 2, best / len(keep)) # when even the best candidate can't fit to px_sigma, loosen to its rms for c in cands: costs[c][sname] = (costs[c][sname] - best) / sig2 for c in cands: d = costs[c] res[c]["delta_chi2"] = round(d["all_points"], 1) worst_drop = min(d, key=d.get) res[c]["robust_delta_chi2"] = round(d[worst_drop], 1) res[c]["robust_by"] = worst_drop return dict(sorted(res.items(), key=lambda kv: (kv[1]["robust_delta_chi2"], kv[1]["delta_chi2"]))) def sanity(pose: dict, pts: list[dict], horizon_row: float | None = None) -> list[str]: """Self-consistency check before reporting a camera position. Mistakes made before: putting the depression angle of some target in the frame into pitch as the camera's pitch, and reporting a camera position whose elevation doesn't match the depression angle in the frame (a 4 m height difference with a 7.6° depression angle, off by a factor of four).""" H = pose["_frame"]["image_size"][1] e0, n0, h_cam, _yaw, pitch, _roll, f = pose["_params"] lat0, lon0 = pose["_frame"]["lat0"], pose["_frame"]["lon0"] kx, ky = _frame(lat0) out = [(f"Self-check pitch={pitch:.1f}°: the true horizon (sea horizon / distant flat horizon) should fall on row " f"{H / 2 - f * math.tan(math.radians(pitch)):.0f} of the frame ({H} rows, frame center {H // 2}). " f"If the sea horizon in the photo is not near this row, pitch was filled in wrong: the target's depression angle ≠ the camera's pitch.")] if horizon_row is not None: pitch_h = math.degrees(math.atan2(H / 2 - float(horizon_row), f)) d = abs(pitch_h - pitch) out.append(f" Sea horizon in the photo is on row {float(horizon_row):.0f} → pitch should be {pitch_h:.1f}° (current {pitch:.1f}°, off by {d:.1f}°)") if d > 2.0: out.append(f" !! pitch contradicts the sea horizon by {d:.1f}° > 2°: calibrate pitch from the sea horizon first, then project/solve") out.append(f"Self-check camera height {h_cam:.1f} (same datum as each point's h); the \"expected row\" in the table below must match the actual row of that feature in the photo:") for q in pts: e = (q["ll"][1] - lon0) * kx - e0 n = (q["ll"][0] - lat0) * ky - n0 dist = math.hypot(e, n) if dist < 1.0: continue dh = h_cam - q.get("h", 0.0) dep = math.degrees(math.atan2(dh, dist)) row = H / 2 + f * math.tan(math.radians(dep + pitch)) note = "" if "px" in q and len(q["px"]) > 1: note = f" actual row {q['px'][1]:.0f}, off by {abs(row - q['px'][1]):.0f} px" out.append(f" {q.get('name', '?'):<14} horizontal {dist:6.0f} m height diff {dh:6.1f} m geometric depression {dep:5.1f}° expected row {row:6.0f}{note}") return out def draw(photo: Path, pose: dict, pts: list[dict], out: Path, observed: bool) -> None: W, H = pose["_frame"]["image_size"] im = open_photo(photo, "photo", frame=frame_of(pose["_frame"]), size=[W, H]).convert("RGB") sx, sy = im.width / W, im.height / H lat0, lon0 = pose["_frame"]["lat0"], pose["_frame"]["lon0"] kx, ky = _frame(lat0) enu = np.array([[(q["ll"][1] - lon0) * kx, (q["ll"][0] - lat0) * ky, q.get("h", 0.0)] for q in pts]) uv, z = project(np.array(pose["_params"]), enu, W, H) d = ImageDraw.Draw(im) for q, (u, v), zz in zip(pts, uv, z): if observed and "px" in q: ox, oy = q["px"][0] * sx, q["px"][1] * sy d.line([ox - 9, oy, ox + 9, oy], fill="lime", width=3) d.line([ox, oy - 9, ox, oy + 9], fill="lime", width=3) if zz > 0 and not math.isnan(u): cx, cy = u * sx, v * sy d.ellipse([cx - 7, cy - 7, cx + 7, cy + 7], outline="red", width=3) d.text((cx + 9, cy - 8), q.get("name", ""), fill="yellow") im.save(out, quality=90) def _neg_coords(argv: list[str]) -> list[str]: """argparse treats negative coordinates like -1.45,-48.5 as option names; prefixing a space makes them plain values (float ignores the space). Needed for any photo in the southern or western hemisphere.""" return [" " + a if re.match(r"^-\d[\d.]*(,-?[\d.]+)+$", a) else a for a in argv] def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = ap.add_subparsers(dest="cmd", required=True) s = sub.add_parser("solve") s.add_argument("spec", type=Path) s.add_argument("--search-radius", type=float, default=300, help="radius of uncertainty of the initial position m") s.add_argument("--restarts", type=int, default=40) s.add_argument("--photo", type=Path, help="draw observed points (green crosses) and reprojected points (red circles)") s.add_argument("--out", type=Path, help="overlay output") s.add_argument("--save", type=Path, default=Path("pose.json")) s.add_argument("--pt-sigma", type=float, default=5.0, help="coordinate error of the control points themselves m (clicking a building corner on satellite imagery: typically 5–15 m). The error radius uses it for Monte Carlo; set 0 to fall back to pixel noise only") ck = sub.add_parser("check", help="score discrete candidate camera positions: each candidate solves only the heading; compare reprojection errors") ck.add_argument("spec", type=Path) ck.add_argument("--cands", type=Path, required=True, help='{"cand_name": [lat, lon] or [lat, lon, height]}; without height, init.height is used') ck.add_argument("--px-sigma", type=float, default=5.0, help="pixel measurement error, used to convert to chi2") ck.add_argument("--save", type=Path, default=Path("pose_check.json")) pr = sub.add_parser("project") pr.add_argument("--pose", type=Path, required=True) pr.add_argument("--points", type=Path, required=True, help='[{"name":…,"ll":[lat,lon],"h":…}] or {name:[lat,lon,h]}') pr.add_argument("--photo", type=Path, required=True) pr.add_argument("--out", type=Path, required=True) pr.add_argument("--horizon", type=float, help="row of the sea horizon / distant flat horizon in the photo, in the frame of the pose's points: used to calibrate and validate pitch") args = ap.parse_args(_neg_coords(sys.argv[1:])) if args.cmd == "solve": spec = json.loads(args.spec.read_text(encoding="utf-8")) pose = solve(spec, args.search_radius, args.restarts, pt_sigma=args.pt_sigma) args.save.write_text(json.dumps(pose, ensure_ascii=False, indent=1), encoding="utf-8") show = {k: v for k, v in pose.items() if not k.startswith("_")} print(json.dumps(show, ensure_ascii=False, indent=1)) if pose["rms_px"] > 15: print("Note: reprojection error is high; a point may be mismatched, the height datum inconsistent, or the initial value too far off (increase --search-radius / --restarts)") loo = pose["leave_one_out"] if loo.get("suspect"): print(f"Note: the per-point check flagged a suspected misidentified point {loo['suspect']}: go back and verify its feature and coordinates first, then decide whether to keep it") elif loo.get("most_improved"): m = loo["most_improved"] print(f"Per-point check: after dropping \"{m['name']}\" the remaining rms is {m['rest_rms_px']} px (median of other drops {m['median_rest_rms_px']} px); below the suspect threshold, but if the gap is large, still check it first") for line in sanity(pose, spec["points"]): print(line) if args.photo and args.out: draw(args.photo, pose, spec["points"], args.out, observed=True) print(f"overlay -> {args.out}") elif args.cmd == "check": spec = json.loads(args.spec.read_text(encoding="utf-8")) cands = json.loads(args.cands.read_text(encoding="utf-8")) out = check(spec, cands, args.px_sigma) args.save.write_text(json.dumps(out, ensure_ascii=False, indent=1), encoding="utf-8") for name, v in out.items(): print(f"{name:24s} rms {v['rms_px']:6.1f} px Δchi2 {v['delta_chi2']:8.1f} robust Δchi2 {v['robust_delta_chi2']:8.1f} ({v['robust_by']})" f" heading {v['heading']:6.1f} pitch {v['pitch']:5.1f}") print(f"-> {args.save} (only robust Δchi2 > 9 counts as inconsistent: dropping any one point can't rescue it; when two candidates differ only on one point, verify that point first)") else: pose = json.loads(args.pose.read_text(encoding="utf-8")) raw = json.loads(args.points.read_text(encoding="utf-8")) pts = raw if isinstance(raw, list) else [{"name": k, "ll": v[:2], "h": (v[2] if len(v) > 2 else 0.0)} for k, v in raw.items()] draw(args.photo, pose, pts, args.out, observed=False) for line in sanity(pose, pts, args.horizon): print(line) print(args.out) if __name__ == "__main__": # Chinese-locale Windows outputs GBK by default: it crashes on m², ñ, and the Chinese the agent reads is garbled sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace") main() -
refresh.py 26.4 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = [] # /// """Maintainer tool: re-fetch the bundled lookup tables from their sources and rewrite them in the shared formats. Global tables go to data/; the China pack's tables go to regions/cn/data/ in the prefix-table and admin-table formats (regions/README.md). Lookups never call this; they read the JSON only. Contributed packs ship their data with `_meta.source` and don't need a parser here. refresh.py [table|all] tables: calling_codes driving_side territories cn/admin cn/plates cn/area_codes refresh.py all --from-dir D parse already-downloaded source files instead of fetching (names in LOCAL_NAMES) cn/plates and cn/area_codes resolve place names against cn/admin, so refresh cn/admin first (all does). """ from __future__ import annotations import argparse from _net import curl_args, PROXY_HELP import html as H import json import os import re import subprocess import sys from datetime import date from pathlib import Path HERE = Path(__file__).resolve().parent DATA = HERE.parent / "data" CN = HERE.parent / "regions" / "cn" / "data" UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36" SOURCES = { "calling_codes": ["https://en.wikipedia.org/wiki/List_of_telephone_country_codes"], "driving_side": ["https://en.wikipedia.org/wiki/Left-_and_right-hand_traffic"], "territories": ["https://en.wikipedia.org/wiki/List_of_dependent_territories"], "cn/admin": ["https://raw.githubusercontent.com/modood/Administrative-divisions-of-China/master/dist/pca-code.json"], "cn/plates": ["https://zh.wikipedia.org/zh-cn/中华人民共和国民用机动车号牌"], "cn/area_codes": ["https://zh.wikipedia.org/zh-cn/中国大陆固定电话号码"], } LOCAL_NAMES = {"cn/plates": "plates_zh.html", "cn/area_codes": "areacodes2_zh.html", "calling_codes": "calling_en.html", "driving_side": "driving_en.html", "territories": "dependent_en.html", "cn/admin": "pca-code.json"} LICENSE = {"cn/admin": "WTFPL (modood/Administrative-divisions-of-China)"} WIKI_LICENSE = "Derived from Wikipedia, CC BY-SA 4.0" # Letter splits inside municipalities: source is common knowledge of the former prefecture divisions; the Wikipedia page only goes down to “重庆市” (Chongqing). Marked unverified; verify before use. MUNICIPAL_LETTER_NOTES = { "渝": {"A": "main urban districts", "B": "main urban districts", "C": "永川、江津、合川、璧山、铜梁、大足、荣昌、潼南 (former 永川 prefecture)", "D": "main urban districts (added later)", "F": "万州、开州、梁平、忠县、云阳、奉节、巫山、巫溪、城口 (former 万县 prefecture)", "G": "涪陵、南川、垫江、丰都、武隆 (former 涪陵 prefecture)", "H": "黔江、石柱、秀山、酉阳、彭水 (former 黔江 prefecture)"}, } # Wikipedia's “List of dependent territories” leaves out overseas territories integrated into the home country (French overseas departments, Spain's Canaries, US Hawaii…), but they are exactly the intersection that “IP country × hinted continent” is looking for, so they are added here. # Sources: Wikipedia Overseas France / Outermost regions of the EU / per-country articles; region/subregion follow the UN geoscheme. INTEGRAL_OVERSEAS = [ {"name": "French Guiana", "sovereign": "France", "region": "Americas", "subregion": "South America", "status": "Overseas department and region (integral part of France, EU)"}, {"name": "Guadeloupe", "sovereign": "France", "region": "Americas", "subregion": "Caribbean", "status": "Overseas department and region"}, {"name": "Martinique", "sovereign": "France", "region": "Americas", "subregion": "Caribbean", "status": "Overseas department and region"}, {"name": "Réunion", "sovereign": "France", "region": "Africa", "subregion": "Eastern Africa", "status": "Overseas department and region"}, {"name": "Mayotte", "sovereign": "France", "region": "Africa", "subregion": "Eastern Africa", "status": "Overseas department and region"}, {"name": "Saint Martin", "sovereign": "France", "region": "Americas", "subregion": "Caribbean", "status": "Overseas collectivity"}, {"name": "Saint Barthélemy", "sovereign": "France", "region": "Americas", "subregion": "Caribbean", "status": "Overseas collectivity"}, {"name": "Saint Pierre and Miquelon", "sovereign": "France", "region": "Americas", "subregion": "Northern America", "status": "Overseas collectivity"}, {"name": "French Polynesia", "sovereign": "France", "region": "Oceania", "subregion": "Polynesia", "status": "Overseas collectivity"}, {"name": "New Caledonia", "sovereign": "France", "region": "Oceania", "subregion": "Melanesia", "status": "Sui generis collectivity"}, {"name": "Wallis and Futuna", "sovereign": "France", "region": "Oceania", "subregion": "Polynesia", "status": "Overseas collectivity"}, {"name": "Canary Islands", "sovereign": "Spain", "region": "Africa", "subregion": "Northern Africa (Atlantic)", "status": "Autonomous community (integral part of Spain)"}, {"name": "Ceuta", "sovereign": "Spain", "region": "Africa", "subregion": "Northern Africa", "status": "Autonomous city (integral part of Spain)"}, {"name": "Melilla", "sovereign": "Spain", "region": "Africa", "subregion": "Northern Africa", "status": "Autonomous city (integral part of Spain)"}, {"name": "Azores", "sovereign": "Portugal", "region": "Europe", "subregion": "Southern Europe (Atlantic)", "status": "Autonomous region (integral part of Portugal)"}, {"name": "Madeira", "sovereign": "Portugal", "region": "Europe", "subregion": "Southern Europe (Atlantic, off Africa)", "status": "Autonomous region (integral part of Portugal)"}, {"name": "Hawaii", "sovereign": "United States", "region": "Oceania", "subregion": "Polynesia", "status": "State (integral part of the US)"}, {"name": "Alaska", "sovereign": "United States", "region": "Americas", "subregion": "Northern America", "status": "State (integral part of the US)"}, {"name": "Bonaire", "sovereign": "Netherlands", "region": "Americas", "subregion": "Caribbean", "status": "Special municipality (integral part of the Netherlands)"}, {"name": "Sint Eustatius", "sovereign": "Netherlands", "region": "Americas", "subregion": "Caribbean", "status": "Special municipality"}, {"name": "Saba", "sovereign": "Netherlands", "region": "Americas", "subregion": "Caribbean", "status": "Special municipality"}, {"name": "Svalbard", "sovereign": "Norway", "region": "Europe", "subregion": "Northern Europe (Arctic)", "status": "Unincorporated area (integral part of Norway)"}, {"name": "Easter Island", "sovereign": "Chile", "region": "Oceania", "subregion": "Polynesia", "status": "Special territory (integral part of Chile)"}, {"name": "Galápagos Islands", "sovereign": "Ecuador", "region": "Americas", "subregion": "South America (Pacific)", "status": "Province (integral part of Ecuador)"}, {"name": "Andaman and Nicobar Islands", "sovereign": "India", "region": "Asia", "subregion": "Southern Asia (Bay of Bengal)", "status": "Union territory (integral part of India)"}, {"name": "Kaliningrad Oblast", "sovereign": "Russia", "region": "Europe", "subregion": "Eastern Europe (exclave on the Baltic)", "status": "Oblast (integral part of Russia)"}, {"name": "Okinawa", "sovereign": "Japan", "region": "Asia", "subregion": "Eastern Asia", "status": "Prefecture (integral part of Japan)"}, ] # ---------------------------------------------------------------- HTML table parsing (handles rowspan/colspan) def _clean(c: str) -> str: c = re.sub(r"<sup[^>]*>.*?</sup>", "", c, flags=re.S) c = re.sub(r"<br\s*/?>", " | ", c) c = H.unescape(re.sub(r"<[^>]+>", "", c)) c = re.sub(r"\[[^\]]*\]", "", c) return re.sub(r"\s+", " ", c).strip() def _tables(html: str) -> list[list[list[str]]]: out = [] for t in re.findall(r"<table[^>]*>(.*?)</table>", html, re.S): rows, pending = [], {} for tr in re.findall(r"<tr[^>]*>(.*?)</tr>", t, re.S): cells = re.findall(r"<t([dh])([^>]*)>(.*?)</t[dh]>", tr, re.S) row, col, k = [], 0, 0 while k < len(cells) or col in pending: if col in pending: text, left = pending[col] row.append(text) if left <= 1: del pending[col] else: pending[col] = (text, left - 1) col += 1 continue _, attrs, body = cells[k] k += 1 text = _clean(body) rs = re.search(r'rowspan="?(\d+)', attrs) cs = re.search(r'colspan="?(\d+)', attrs) n = int(cs.group(1)) if cs else 1 for _ in range(n): row.append(text) if rs and int(rs.group(1)) > 1: pending[col] = (text, int(rs.group(1)) - 1) col += 1 rows.append(row) out.append(rows) return out def _sections_h3(html: str) -> list[tuple[str, str]]: parts = re.split(r"<h3[^>]*>", html) secs = [] for part in parts[1:]: if "</h3>" not in part: continue title, body = part.split("</h3>", 1) secs.append((_clean(title), body.split("<h2", 1)[0])) return secs # ---------------------------------------------------------------- per-table parsers def parse_cn_plates(html: str) -> dict: out = {} for title, body in _sections_h3(html): m = re.match(r"^(.+?)((.))$", title) if not m: continue prov, abbr = m.group(1), m.group(2) letters = {} for li in re.findall(r"<li[^>]*>(.*?)</li>", body, re.S): text = _clean(li) text = re.split(r"[。;;]", text)[0] text = re.sub(r"^(参见:.*?)(?=[A-Z](?:[/、,,]|\s))", "", text) text = re.sub(r"^(汽车|小型汽车|大型汽车)\s*", "", text) if "摩托车" in text or "拖拉机" in text: continue mm = re.match(r"^([A-Z](?:\s*[/、,,–\-~~至]\s*[A-Z])*)\s*[::]?\s*(.+)$", text) if not mm: continue place = mm.group(2).strip() spec = mm.group(1) Ls: list[str] = [] for seg in re.split(r"[/、,,]", spec): seg = seg.strip() r = re.match(r"^([A-Z])\s*[–\-~~至]\s*([A-Z])$", seg) if r: Ls += [chr(c) for c in range(ord(r.group(1)), ord(r.group(2)) + 1) if chr(c) not in "IO"] elif seg: Ls.append(seg) for L in Ls: letters[L] = place if letters: out[abbr] = {"province": prov, "letters": letters} for abbr, notes in MUNICIPAL_LETTER_NOTES.items(): if abbr in out: out[abbr]["letter_notes_unverified"] = notes return out def parse_cn_area_codes(html: str) -> dict: out = {} for t in _tables(html): if not t or not t[0] or t[0][0] != "区号": continue for row in t[1:]: if len(row) < 3 or not re.match(r"^\d{2,4}$", row[0]): continue code = "0" + row[0] entry = {"admin1": row[1], "admin2": [x.strip() for x in row[2].split("|") if x.strip()], "digits": row[3] if len(row) > 3 else "", "note": row[4] if len(row) > 4 else ""} # "/" in the digits column marks a retired code; the note alone isn't enough (023's note tells the history of 811 being retired) if entry["digits"] == "/": entry["deprecated"] = True out[code] = entry return out def parse_calling_codes(html: str) -> dict: out = {} for t in _tables(html): if len(t) < 50 or not t[0] or t[0][0] != "Serving": continue for row in t[1:]: if len(row) < 2 or not re.match(r"^\d", row[1]): continue country = row[0] m = re.match(r"^(\d+)\s*(?:\(([^)]*)\))?", row[1]) if not m: continue code = m.group(1) subs = [s.strip() for s in (m.group(2) or "").split(",") if s.strip()] keys = [f"{code}-{s}" for s in subs] or [code] for k in keys: out.setdefault(k, []).append({"country": country, "utc": row[2] if len(row) > 2 else ""}) return out DRIVING_SUPPLEMENT = { # regions without their own row in the Wikipedia table (source: each region's article, common knowledge) "Hong Kong": ("left", "kept from British rule, opposite to the mainland"), "Macau": ("left", "kept from Portuguese rule, opposite to the mainland"), "Taiwan": ("right", ""), "French Guiana": ("right", "French overseas department"), "Guadeloupe": ("right", "French overseas department"), "Martinique": ("right", "French overseas department"), "Réunion": ("right", "French overseas department"), "Mayotte": ("right", "French overseas department"), "New Caledonia": ("right", "French territory"), "French Polynesia": ("right", "French territory"), "Puerto Rico": ("right", "US territory"), "Guam": ("right", "US territory"), "U.S. Virgin Islands": ("left", "US territory, a rare left-hand one"), "American Samoa": ("right", "US territory"), "Northern Mariana Islands": ("right", "US territory"), "Greenland": ("right", "Denmark"), "Faroe Islands": ("right", "Denmark"), "Aruba": ("right", "Netherlands"), "Curaçao": ("right", "Netherlands"), "Sint Maarten": ("right", "Netherlands"), "Gibraltar": ("right", "British territory, a rare right-hand one"), "Bermuda": ("left", "British territory"), "Cayman Islands": ("left", "British territory"), "British Virgin Islands": ("left", "British territory"), "Anguilla": ("left", "British territory"), "Montserrat": ("left", "British territory"), "Turks and Caicos Islands": ("left", "British territory"), "Falkland Islands": ("left", "British territory"), "Saint Helena": ("left", "British territory"), "Isle of Man": ("left", "British Crown Dependency"), "Jersey": ("left", "British Crown Dependency"), "Guernsey": ("left", "British Crown Dependency"), "Cook Islands": ("left", "New Zealand associated state"), "Niue": ("left", "New Zealand associated state"), "Tokelau": ("left", "New Zealand territory"), "Canary Islands": ("right", "Spain"), "Azores": ("right", "Portugal"), "Madeira": ("right", "Portugal"), "Svalbard": ("right", "Norway"), } def parse_driving_side(html: str) -> dict: """The table's "Country" header spans two columns: sovereign state, then a sub-unit for states listed in parts (China → Mainland / Hong Kong / Macau). Keying every row by the first column let the last sub-row overwrite the country (China came out as LHT with Macau's note), so sub-rows are resolved explicitly.""" out: dict = {} groups: dict[str, list[tuple[str, dict]]] = {} for t in _tables(html): if len(t) < 100 or not t[0] or t[0][0] != "Country": continue for row in t[1:]: if len(row) < 2: continue idx = next((i for i, c in enumerate(row) if re.match(r"^(LHT|RHT)", c.upper())), None) if idx is None: continue e = {"side": "left" if row[idx].upper().startswith("LHT") else "right", "switched": row[idx + 1] if len(row) > idx + 1 else "", "note": row[idx + 2] if len(row) > idx + 2 else ""} if idx >= 2 and row[1] != row[0]: groups.setdefault(re.sub(r" and overseas territories$", "", row[0]), []).append((row[1], e)) else: out[row[0]] = e for parent, subs in groups.items(): main = [e for sub, e in subs if sub == "Mainland" or sub.endswith(" proper") or sub.startswith("Contiguous")] if main: out[parent] = main[0] elif len({e["side"] for _, e in subs}) == 1: # Listed by province/state, all the same side (Canada): the country takes that side, provinces aren't countries out[parent] = dict(subs[0][1], note="; ".join(f"{sub}: {e['note']}" for sub, e in subs if e["note"])) continue for sub, e in subs: if e in main: continue out[sub] = dict(e, part_of=parent, note="; ".join(x for x in (f"part of {parent}", e["note"]) if x)) for k, (side, note) in DRIVING_SUPPLEMENT.items(): if k not in out: out[k] = {"side": side, "switched": "", "note": note, "curated": True} return out def parse_territories(html: str) -> dict: items = [] for t in _tables(html): if not t or not t[0] or t[0][0] != "Name" or "Sovereign state" not in t[0]: continue hi = {h: i for i, h in enumerate(t[0])} for row in t[1:]: if len(row) < len(t[0]) - 1: continue items.append({"name": row[hi["Name"]], "sovereign": row[hi["Sovereign state"]], "region": row[hi["UN region"]], "subregion": row[hi["UN subregion"]], "status": row[hi.get("Legal status", len(row) - 1)], "population": row[hi.get("Population (2016)", 1)], "area_km2": row[hi.get("Area (km)", 2)]}) names = {it["name"].lower() for it in items} for it in INTEGRAL_OVERSEAS: if it["name"].lower() not in names: items.append(dict(it, population="", area_km2="", curated=True)) by_sov: dict = {} for it in items: by_sov.setdefault(it["sovereign"], []).append(it) return {"by_sovereign": by_sov, "count": len(items), "note": "Wikipedia's “List of dependent territories” excludes overseas territories integrated into the home country, such as French overseas departments; INTEGRAL_OVERSEAS adds them (curated=true)"} def parse_cn_admin(raw: str) -> dict: nodes = json.loads(raw) items = [] def walk(ns, parent, level): for n in ns: name, code = n.get("name", ""), n.get("code", "") ch = n.get("children") or [] if level == 2 and name in ("市辖区", "县", "省直辖县级行政区划", "自治区直辖县级行政区划", "市"): walk(ch, parent, 3) # dummy level for municipalities / province-administered units: attach children directly to the province continue items.append({"name": name, "code": code, "level": level, "parent": parent}) walk(ch, name, level + 1) walk(nodes, "", 1) return {"items": items} # ---------------------------------------------------------------- China pack: raw tables → shared formats CN_ADMIN_LEVELS = {1: "admin1", 2: "admin2", 3: "district"} # must match regions/cn/region.json admin_levels class _CNAdmin: def __init__(self, items: list[dict]): self.items = items self.by: dict[str, list[dict]] = {} for it in items: self.by.setdefault(it["name"], []).append(it) def chain(self, it: dict) -> list[dict]: out, cur = [it], it while cur.get("parent"): nxt = self.by.get(cur["parent"]) if not nxt: break cur = nxt[0] out.append(cur) return list(reversed(out)) def under(self, prov: str, name: str) -> dict | None: """The one item called name (or name without its 市/区/县 suffix) inside province prov.""" cands = self.by.get(name) or [it for n, its in self.by.items() if n.rstrip("市区县") == name.rstrip("市区县") for it in its] cands = [it for it in cands if prov in [c["name"] for c in self.chain(it)]] return cands[0] if len(cands) == 1 else None def match(self, it: dict) -> dict: return {CN_ADMIN_LEVELS[c["level"]]: c["name"] for c in self.chain(it)} def _cn_places(adm: _CNAdmin, prov: str, place: str) -> list[dict]: """Free-text place from a Wikipedia table cell → matches. Unresolvable text (vehicle classes, "直辖市全境", historic areas) stays at province level with the text as the note, instead of becoming a fake place.""" place = place.strip() if place == prov or place.rstrip("市") == prov.rstrip("市"): return [{"admin1": prov}] m = re.match(r"^(.+?)((.+))$", place) head, inner = (m.group(1), m.group(2)) if m else (place, "") it = adm.under(prov, head) if not it: return [{"admin1": prov, "note": place}] if inner: subs = [x.strip() for x in re.split(r"[、,,]", inner) if x.strip()] kids = [adm.under(prov, x) for x in subs] if subs and all(kids) and not re.search(r"不含|部分|地级|地区", inner): return [adm.match(k) for k in kids] return [dict(adm.match(it), note=inner)] return [adm.match(it)] def cn_plates_table(raw: dict, adm: _CNAdmin) -> dict: entries: dict[str, list[dict]] = {} for abbr, v in raw.items(): prov = v["province"] entries[abbr] = [{"admin1": prov, "note": "province abbreviation only"}] unv = v.get("letter_notes_unverified") or {} for letter, place in v["letters"].items(): rows = [] for p in re.split(r"[、,,/]", place): if not p.strip(): continue for r in _cn_places(adm, prov, p): if set(r) <= {"admin1", "note"} and not r.get("note") and letter in unv: r.update(note=f"whole municipality; district split from common knowledge: {unv[letter]}", unverified=True) elif set(r) <= {"admin1", "note"} and not r.get("note"): r["note"] = "whole municipality" if prov.endswith("市") else "whole province" rows.append(r) entries[abbr + letter] = _dedup(rows) return entries def cn_area_codes_table(raw: dict, adm: _CNAdmin) -> dict: entries: dict[str, list[dict]] = {} for code, e in raw.items(): note = ("deprecated; " if e.get("deprecated") else "") + (e.get("note") or "") rows = [] for a in e["admin2"] or [e["admin1"]]: for r in _cn_places(adm, e["admin1"], a): r["note"] = "; ".join(x for x in (r.get("note", ""), note) if x) if e.get("digits"): r["digits"] = e["digits"] rows.append(r) entries[code] = _dedup(rows) return entries def _dedup(rows: list[dict]) -> list[dict]: """One match per place: rows naming the same place (several vehicle classes under one letter) merge, notes joined.""" out: dict[str, dict] = {} for r in rows: k = json.dumps({lv: r[lv] for lv in ("admin1", "admin2", "district") if r.get(lv)}, ensure_ascii=False, sort_keys=True) if k not in out: out[k] = dict(r) continue notes = [x for x in out[k].get("note", "").split("; ") if x] notes += [x for x in r.get("note", "").split("; ") if x and x not in notes] out[k]["note"] = "; ".join(notes) return list(out.values()) PLATE_META = {"kind": "plate", "normalize": {"strip": " ·-", "upper": True}, "key_pattern": "^[\\u4e00-\\u9fff][A-Z]?", "usage": "first Han character (province) + issuing letter, e.g. <abbr><letter>; the rest of the plate is ignored", "note": "Municipality letter splits come from common knowledge, marked unverified; Hong Kong/Macau/Taiwan, military and police plates are not in the table.", "miss_note": "province abbreviation not in the table"} AREA_META = {"kind": "area-code", "normalize": {"digits_only": True, "ensure_prefix": "0"}, "usage": "landline area code; accepts 0817, 0817-1234567, (0817) 123. 010/02X are 3 digits, the rest 4", "miss_note": "not a mainland China landline area code (mobile number, 400/800, foreign number)"} # ---------------------------------------------------------------- fetch / write def _fetch(url: str, proxy: str | None) -> str: cmd = ["curl", "-q", "-s", "-m", "90", "-A", UA, "-L"] cmd += curl_args(proxy) r = subprocess.run(cmd + [url], capture_output=True) if r.returncode != 0 or len(r.stdout) < 1000: sys.exit(f"fetch failed: {url} (check service availability with doctor.py --network)") return r.stdout.decode("utf-8", "replace") def _write(path: Path, payload: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload, ensure_ascii=False, indent=1), encoding="utf-8") print(f"{path.relative_to(HERE.parent)}: {payload['_meta'].get('count')} entries ({path.stat().st_size // 1024} KB)") def refresh(name: str, raw: str) -> None: today = date.today().isoformat() meta = {"source": SOURCES[name], "fetched": today, "license": LICENSE.get(name, WIKI_LICENSE)} if name in ("calling_codes", "driving_side", "territories"): data = {"calling_codes": parse_calling_codes, "driving_side": parse_driving_side, "territories": parse_territories}[name](raw) _write(DATA / f"{name}.json", {"_meta": dict(meta, count=data.get("count") or len(data)), **data}) elif name == "cn/admin": data = parse_cn_admin(raw) _write(CN / "admin.json", {"_meta": dict(meta, count=len(data["items"])), **data}) else: adm = _CNAdmin(json.loads((CN / "admin.json").read_text(encoding="utf-8"))["items"]) if name == "cn/plates": entries, extra = cn_plates_table(parse_cn_plates(raw), adm), PLATE_META else: entries, extra = cn_area_codes_table(parse_cn_area_codes(raw), adm), AREA_META _write(CN / f"{name.split('/')[1]}.json", {"_meta": dict(extra, **meta, count=len(entries)), "entries": entries}) def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("table", nargs="?", default="all", help=f"all or one of {', '.join(SOURCES)}") ap.add_argument("--proxy", default=os.environ.get("GEO_PROXY"), help=PROXY_HELP) ap.add_argument("--from-dir", type=Path, help="directory of already-downloaded source files (for development)") args = ap.parse_args() names = list(SOURCES) if args.table == "all" else [args.table] for name in names: if name not in SOURCES: sys.exit(f"unknown table {name}; one of {', '.join(SOURCES)}") raw = (args.from_dir / LOCAL_NAMES[name]).read_text(encoding="utf-8", errors="replace") if args.from_dir else _fetch(SOURCES[name][0], args.proxy) refresh(name, raw) if __name__ == "__main__": sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace") main() -
regions.py 24.2 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = [] # /// """Region packs: country-specific clues, lookup tables and service preferences, kept out of the core. A pack is a folder regions/<cc>/ (cc = ISO 3166-1 alpha-2, lower case) holding only data and documents: region.json manifest: names, admin levels, lookup tables, preferred services, tips (format: regions/README.md) clues.md clue entries in the shared entry format data/*.json lookup tables in the shared prefix-table format tests.json lookup cases: input → expected matches Packs never contain code. The core scripts read the manifest; a lookup kind a pack doesn't declare is reported as unsupported, never answered from another country's table. list installed packs: code, name, status, lookups, clue entries show <cc> one pack's card: lookups, services, tips, and a one-line index of its clue entries with line numbers lint [cc ...] check packs against the contract (manifest, sources, entry fields, no code, tests); exit 1 on errors Extra pack folders (private packs, tests) can be added with GEO_SLEUTH_REGIONS=<dir>[:<dir>...]; a pack there overrides a bundled pack with the same code. Examples: regions.py list regions.py show cn regions.py lint """ from __future__ import annotations import argparse import json import os import re import sys import unicodedata from pathlib import Path ROOT = Path(__file__).resolve().parent.parent / "regions" BOARD_LEVELS = ["country", "admin1", "admin2", "city", "district", "area", "road", "point"] MATCH_LEVELS = ["admin1", "admin2", "city", "district"] # levels a lookup match may name STATUSES = ("maintained", "community", "stub") ENTRY_FIELDS = ("Look for", "Points to", "Strength", "Counterexamples", "Sources") ALLOWED_SUFFIXES = {".json", ".md"} MERGED = re.compile(r"/|、|;") # "Ghaziabad / Noida" is two places, not one ("and"/"&" stay legal: Jammu and Kashmir is one) _CACHE: dict | None = None # ---------------------------------------------------------------- loading def _dirs() -> list[Path]: extra = [Path(p) for p in os.environ.get("GEO_SLEUTH_REGIONS", "").split(os.pathsep) if p.strip()] return [ROOT, *extra] def packs(reload: bool = False) -> dict[str, dict]: """{code: manifest + {"dir": Path}} for every installed pack (folders starting with _ are templates, skipped).""" global _CACHE if _CACHE is not None and not reload: return _CACHE out: dict[str, dict] = {} for base in _dirs(): if not base.is_dir(): continue for d in sorted(base.iterdir()): f = d / "region.json" if d.name.startswith(("_", ".")) or not f.is_file(): continue try: m = json.loads(f.read_text(encoding="utf-8")) except ValueError: continue m["dir"] = d out[str(m.get("code", d.name)).upper()] = m _CACHE = out return out def _fold(s: str) -> str: s = unicodedata.normalize("NFKC", s or "").strip().lower() return re.sub(r"[\s.()\-_'’]", "", s) def resolve(country: str | None) -> dict | None: """Country code / name / alias → pack, or None when no installed pack matches.""" if not country: return None key = _fold(country) for code, m in packs().items(): if key == code.lower() or key in {_fold(n) for n in [m.get("name", ""), *m.get("names", [])]}: return m return None _TABLES: dict[Path, dict] = {} def load_table(pack: dict, rel: str) -> dict: f = pack["dir"] / rel if f not in _TABLES: _TABLES[f] = json.loads(f.read_text(encoding="utf-8")) return _TABLES[f] # ---------------------------------------------------------------- prefix-table lookups def normalize(value: str, spec: dict) -> str: v = unicodedata.normalize("NFKC", value or "") if spec.get("fullwidth", True) else (value or "") for ch in spec.get("strip", " "): v = v.replace(ch, "") if spec.get("digits_only"): v = re.sub(r"\D", "", v) if spec.get("upper", True): v = v.upper() pre = spec.get("ensure_prefix") if pre and v and not v.startswith(pre): v = pre + v return v def prefix_lookup(pack: dict, kind: str, value: str) -> dict: """Look a value up in one pack's prefix table. Returns {"status": ok|no_match|not_applicable|unsupported, "matches": [...], "note", "source", "fetched"}. not_applicable = the value doesn't have this table's format (e.g. a Latin plate against a table of Han-character prefixes).""" spec = (pack.get("lookups") or {}).get(kind) if not spec: return {"status": "unsupported", "matches": [], "note": f"{pack['code']} pack has no {kind} table", "source": "", "fetched": ""} t = load_table(pack, spec["file"]) if not isinstance(t, dict) or not isinstance(t.get("entries", {}), dict): raise ValueError(f"{spec['file']} must be an object with an 'entries' object") meta = t.get("_meta", {}) src = ", ".join(meta.get("source") or []) if isinstance(meta.get("source"), list) else str(meta.get("source", "")) base = {"source": src, "fetched": meta.get("fetched", "")} v = normalize(value, meta.get("normalize") or {}) target = v if meta.get("key_pattern"): m = re.match(meta["key_pattern"], v) if not m: return {"status": "not_applicable", "matches": [], "note": "", **base} target = m.group(0) entries = t.get("entries") or {} key = next((target[:n] for n in range(len(target), 0, -1) if target[:n] in entries), None) if key is None: return {"status": "no_match", "matches": [], "note": meta.get("miss_note", "not in the table"), **base} note = "" if meta.get("key_pattern") and key != target: note = f"only '{key}' matched; '{target[len(key):]}' is not in the table (new series, or table out of date)" matches = [] for e in entries[key]: row = {"country": pack.get("name", pack["code"]), "country_code": pack["code"], "key": key} row.update(e) if note: row["note"] = "; ".join(x for x in (note, row.get("note", "")) if x) matches.append(row) return {"status": "ok", "matches": matches, "note": "", **base} PACK_ERRORS = (OSError, ValueError, KeyError, TypeError, AttributeError, IndexError, re.error) def safe_lookup(pack: dict, kind: str, value: str) -> dict: """prefix_lookup that turns a broken pack (missing or malformed table) into status "error" instead of an exception.""" try: return prefix_lookup(pack, kind, value) except PACK_ERRORS as exc: return {"status": "error", "matches": [], "source": "", "fetched": "", "note": f"{pack['code']} pack is broken ({type(exc).__name__}: {exc}); run `regions.py lint {pack['code'].lower()}`"} def lookup(kind: str, value: str, country: str | None = None) -> dict: """Prefix lookup across packs. With a country: only that pack (unsupported if it isn't installed or lacks the table). Without: every pack that declares the kind; matches from several countries are all returned.""" if country: p = resolve(country) if not p: have = ", ".join(sorted(packs())) or "none" return {"status": "unsupported", "matches": [], "searched": [], "note": f"no region pack for '{country}' (installed: {have}); use global tools, or contribute a pack (regions/README.md)"} r = safe_lookup(p, kind, value) r["searched"] = [] if r["status"] == "error" else [p["code"]] if r["status"] == "unsupported": r["note"] += f" (its lookups: {', '.join(p.get('lookups') or {}) or 'none'})" return r searched, matches, notes, sources, fetched = [], [], [], [], [] for code, p in packs().items(): if kind not in (p.get("lookups") or {}): continue r = safe_lookup(p, kind, value) if r["status"] == "not_applicable": continue if r["status"] == "error": # one broken pack mustn't take down the other countries' answers notes.append(r["note"]) continue searched.append(code) matches += r["matches"] if r["status"] == "no_match": notes.append(f"{code}: {r['note']}") if r["source"]: sources.append(r["source"]) fetched.append(f"{code} {r['fetched']}") if not searched: return {"status": "unsupported", "matches": [], "searched": [], "note": "; ".join([f"no installed pack has a {kind} table in this format", *notes]), "source": "", "fetched": ""} note = "; ".join(notes) if len({m["country_code"] for m in matches}) > 1: note = "; ".join(x for x in ("matches in several countries: keep each as a hypothesis, or pass --country", note) if x) return {"status": "ok" if matches else "no_match", "matches": matches, "searched": searched, "note": note, "source": " | ".join(sources), "fetched": ", ".join(fetched)} # ---------------------------------------------------------------- admin tables def admin_items(pack: dict) -> list[dict]: rel = pack.get("admin_table") if not rel or not (pack["dir"] / rel).is_file(): return [] try: items = load_table(pack, rel).get("items") or [] if not all(isinstance(it, dict) and "name" in it for it in items): raise ValueError(f"{rel}: items must be objects with a name") return items except PACK_ERRORS: # broken table: treated as absent here; doctor.py and lint report it return [] def level_name(pack: dict, admin_level) -> str: return (pack.get("admin_levels") or {}).get(str(admin_level), "district") def strip_suffix(name: str, pack: dict) -> str: """Name without one of the pack's generic suffixes (CN: 市/省/区/县), for matching names across sources.""" out = name or "" for s in pack.get("name_suffixes") or []: if out.endswith(s) and len(out) > len(s): return out[: -len(s)] return out _INDEX: dict[str, tuple[list[dict], dict]] = {} def admin_index(pack: dict) -> tuple[list[dict], dict]: key = str(pack["dir"]) if key not in _INDEX: items = admin_items(pack) by: dict[str, list[dict]] = {} for it in items: by.setdefault(it["name"], []).append(it) _INDEX[key] = (items, by) return _INDEX[key] def admin_chain(pack: dict, item: dict) -> list[str]: _, by = admin_index(pack) chain, p = [item["name"]], item.get("parent", "") while p: chain.append(p) nxt = by.get(p) p = nxt[0].get("parent", "") if nxt else "" return list(reversed(chain)) def admin_find(name: str, country: str | None = None) -> list[tuple[dict, dict]]: """Exact name first, then a match ignoring the pack's generic suffixes (市/省/区/县 in CN). [(pack, item)]""" pk = [resolve(country)] if country else list(packs().values()) out = [] for p in pk: if not p: continue items, by = admin_index(p) hits = by.get(name) or [it for it in items if strip_suffix(it["name"], p) == strip_suffix(name, p)] out += [(p, it) for it in hits] return out def admin_children(name: str, country: str | None = None) -> tuple[dict | None, list[dict]]: """Direct children of an admin area in the local tables. (pack, items); (None, []) when no pack knows the parent.""" hits = admin_find(name, country) if not hits: return None, [] p, parent = hits[0] kids = [it for it in admin_items(p) if it.get("parent") == parent["name"]] return p, kids def match_from_item(pack: dict, item: dict) -> dict: """Admin-table item → match dict with explicit board levels along its chain.""" _, by = admin_index(pack) row = {"country": pack.get("name", pack["code"]), "country_code": pack["code"]} for nm in admin_chain(pack, item): lv = by[nm][0]["level"] if nm in by else None if lv is not None: row[level_name(pack, lv)] = nm return row def cid(code: str, match: dict, upto: str | None = None) -> str: """Stable candidate id: country code + names along the levels the match states, e.g. CN/广东省/深圳市.""" parts = [code] for lv in MATCH_LEVELS: if match.get(lv): parts.append(match[lv]) if lv == upto: break return "/".join(parts) # ---------------------------------------------------------------- clue index (for show) def clue_index(path: Path) -> list[tuple[int, str, str]]: """[(line number, '##'/'###', text)] — entries get 'title — points to (strength)'.""" out: list[tuple[int, str, str]] = [] lines = path.read_text(encoding="utf-8").splitlines() for i, s in enumerate(lines, 1): if s.startswith("## "): out.append((i, "##", s[3:].strip())) elif s.startswith("### "): title, points, strength = s[4:].strip(), "", "" for t in lines[i: i + 12]: if t.startswith("### ") or t.startswith("## "): break if t.startswith("- Points to:"): points = t.split(":", 1)[1].strip() elif t.startswith("- Strength:"): strength = t.split(":", 1)[1].strip().split(" ")[0].strip(",;") short = points if len(points) <= 70 else points[:68].rsplit(" ", 1)[0] + "…" out.append((i, "###", f"{title} — {short}" + (f" ({strength})" if strength else ""))) return out # ---------------------------------------------------------------- lint def lint(pack: dict) -> tuple[list[str], list[str]]: errs, warns = [], [] d: Path = pack["dir"] code = str(pack.get("code", "")) if not re.fullmatch(r"[A-Z]{2}", code): errs.append(f"code '{code}' must be an ISO 3166-1 alpha-2 code in upper case") if d.name != code.lower(): errs.append(f"folder name '{d.name}' must be the lower-case code '{code.lower()}'") for k in ("name", "names", "status", "docs"): if not pack.get(k): errs.append(f"region.json: '{k}' is required") if pack.get("status") and pack["status"] not in STATUSES: errs.append(f"region.json: status must be one of {STATUSES}") for f in d.rglob("*"): if f.is_file() and f.suffix.lower() not in ALLOWED_SUFFIXES: errs.append(f"{f.relative_to(d)}: packs hold data and documents only (.json/.md); code goes into core scripts through a separate PR") for rel in pack.get("docs") or []: f = d / rel if not f.is_file(): errs.append(f"docs: {rel} not found") continue _lint_entries(f, rel, errs, warns) levels = set((pack.get("admin_levels") or {}).values()) bad_lv = levels - set(MATCH_LEVELS) if bad_lv: errs.append(f"admin_levels: {sorted(bad_lv)} are not board levels {MATCH_LEVELS}") checks = [lambda: _lint_admin(pack, errs)] if pack.get("admin_table") else [] checks += [lambda k=k, v=v: _lint_table(pack, k, v, errs, warns) for k, v in (pack.get("lookups") or {}).items()] checks.append(lambda: _lint_tests(pack, errs)) for check in checks: # a malformed file is an error to report, not a crash try: check() except PACK_ERRORS as exc: errs.append(f"malformed data ({type(exc).__name__}: {exc})") for svc in ("street_view",): v = (pack.get("services") or {}).get(svc) if v and not (Path(__file__).parent / v).is_file(): errs.append(f"services.{svc}: {v} is not a core script") return errs, warns def _lint_entries(f: Path, rel: str, errs: list, warns: list) -> None: lines = f.read_text(encoding="utf-8").splitlines() if len(lines) > 400: warns.append(f"{rel}: {len(lines)} lines; split by topic into several docs (each listed in region.json docs)") if not rel.startswith("clues"): return starts = [i for i, s in enumerate(lines) if s.startswith("### ")] for n, i in enumerate(starts): end = next((j for j in range(i + 1, len(lines)) if lines[j].startswith(("### ", "## "))), len(lines)) body = lines[i + 1: end] missing = [k for k in ENTRY_FIELDS if not any(s.startswith(f"- {k}:") for s in body)] if missing: errs.append(f"{rel}:{i + 1} '{lines[i][4:].strip()}' lacks {', '.join(missing)}") def _lint_table(pack: dict, kind: str, spec: dict, errs: list, warns: list) -> None: f = pack["dir"] / spec.get("file", "") if not f.is_file(): errs.append(f"lookups.{kind}: {spec.get('file')} not found") return try: t = json.loads(f.read_text(encoding="utf-8")) except ValueError as exc: errs.append(f"{spec['file']}: invalid JSON ({exc})") return if not isinstance(t, dict): errs.append(f"{spec['file']}: must be an object with '_meta' and 'entries'") return meta, entries = t.get("_meta") or {}, t.get("entries") for k in ("source", "fetched", "license"): if not meta.get(k): errs.append(f"{spec['file']}: _meta.{k} is required (where the data comes from, when, under which license)") if meta.get("fetched") and not re.fullmatch(r"\d{4}-\d{2}-\d{2}", str(meta["fetched"])): errs.append(f"{spec['file']}: _meta.fetched must be YYYY-MM-DD") if meta.get("key_pattern"): try: re.compile(meta["key_pattern"]) except re.error as exc: errs.append(f"{spec['file']}: key_pattern doesn't compile ({exc})") if not isinstance(entries, dict) or not entries: errs.append(f"{spec['file']}: 'entries' must be a non-empty object of key → [match, ...]") return if meta.get("count") is not None and meta["count"] != len(entries): errs.append(f"{spec['file']}: _meta.count {meta['count']} != {len(entries)} entries") if f.stat().st_size > 8 * 1024 * 1024: warns.append(f"{spec['file']}: over 8 MB") for key, rows in entries.items(): if not isinstance(rows, list) or not rows: errs.append(f"{spec['file']}: '{key}' must map to a non-empty list (several places → several matches, never one merged name)") continue for r in rows: if not isinstance(r, dict) or not any(r.get(lv) for lv in MATCH_LEVELS): errs.append(f"{spec['file']}: '{key}' has a match without any of {MATCH_LEVELS}") break merged = [r[lv] for lv in MATCH_LEVELS if r.get(lv) and MERGED.search(str(r[lv]))] if merged: errs.append(f"{spec['file']}: '{key}' names {merged}; list each place as its own match") break extra = [k for k in r if k in BOARD_LEVELS and k not in MATCH_LEVELS] if extra: errs.append(f"{spec['file']}: '{key}' uses {extra}; a match names admin levels only") break def _lint_admin(pack: dict, errs: list) -> None: rel = pack["admin_table"] f = pack["dir"] / rel if not f.is_file(): errs.append(f"admin_table: {rel} not found") return t = json.loads(f.read_text(encoding="utf-8")) meta = t.get("_meta") or {} for k in ("source", "fetched", "license"): if not meta.get(k): errs.append(f"{rel}: _meta.{k} is required") items = t.get("items") or [] names = {it.get("name") for it in items} lv = {str(it.get("level")) for it in items} unknown = lv - set((pack.get("admin_levels") or {}).keys()) if unknown: errs.append(f"{rel}: levels {sorted(unknown)} have no entry in admin_levels") orphans = [it["name"] for it in items if it.get("parent") and it["parent"] not in names] if orphans: errs.append(f"{rel}: {len(orphans)} items name a parent that isn't in the table (e.g. {orphans[:3]})") def _lint_tests(pack: dict, errs: list) -> None: f = pack["dir"] / "tests.json" kinds = set(pack.get("lookups") or {}) if not kinds: return if not f.is_file(): errs.append("tests.json is required when the pack has lookups (at least one case per lookup kind)") return cases = json.loads(f.read_text(encoding="utf-8")) seen = set() for c in cases: seen.add(c.get("kind")) r = prefix_lookup(pack, c["kind"], c["value"]) got = sorted(json.dumps({k: m[k] for k in MATCH_LEVELS if m.get(k)}, ensure_ascii=False, sort_keys=True) for m in r["matches"]) want = sorted(json.dumps({k: m[k] for k in MATCH_LEVELS if m.get(k)}, ensure_ascii=False, sort_keys=True) for m in c.get("expect", [])) if got != want: errs.append(f"tests.json: {c['kind']} {c['value']} → {got}, expected {want}") for k in sorted(kinds - seen): errs.append(f"tests.json: no case for lookup kind '{k}'") # ---------------------------------------------------------------- CLI def _count_entries(p: dict) -> int: n = 0 for rel in p.get("docs") or []: f = p["dir"] / rel if f.is_file(): n += sum(1 for s in f.read_text(encoding="utf-8").splitlines() if s.startswith("### ")) return n def cmd_list(_args) -> None: ps = packs() if not ps: print("No region packs installed.") return for code, p in sorted(ps.items()): lk = ", ".join(p.get("lookups") or {}) or "-" print(f"{code.lower()} {p.get('name', '')} [{p.get('status', '?')}] lookups: {lk} clue entries: {_count_entries(p)}") print("\n`regions.py show <cc>` for one pack's card and clue index. No pack for a country = global tools and references only.") def cmd_show(args) -> None: p = resolve(args.code) if not p: sys.exit(f"No pack for '{args.code}' (installed: {', '.join(sorted(packs())) or 'none'}). Use global tools; references/clues/global.md has cross-country clues.") d: Path = p["dir"] rel_dir = d.relative_to(ROOT.parent) if d.is_relative_to(ROOT.parent) else d print(f"# {p['code']} {p.get('name', '')} [{p.get('status', '?')}] ({rel_dir}/)") if p.get("admin_levels"): names = p.get("level_names") or {} print("Admin levels: " + ", ".join(f"{k}={v}" + (f" ({names[v]})" if names.get(v) else "") for k, v in p["admin_levels"].items())) for kind, spec in (p.get("lookups") or {}).items(): t = load_table(p, spec["file"]) meta = t.get("_meta", {}) print(f"Lookup {kind}: {len(t.get('entries') or {})} keys, fetched {meta.get('fetched', '?')} → `clues.py lookup {kind} <value> --country {p['code'].lower()}`" + (f" {meta['usage']}" if meta.get("usage") else "")) if p.get("admin_table"): print(f"Admin table: {len(admin_items(p))} areas (used by `board.py children` and `clues.py lookup admin`)") for k, v in (p.get("services") or {}).items(): print(f"Service {k}: {v if isinstance(v, str) else json.dumps(v, ensure_ascii=False)}") for k, v in (p.get("tips") or {}).items(): print(f"Tip {k}: {v}") for rel in p.get("docs") or []: f = d / rel if not f.is_file(): continue print(f"\n## {rel_dir}/{rel} — read only the entries you need (line numbers below)") for ln, kind, text in clue_index(f): print(f" {ln:>4} {text}" if kind == "###" else f" {ln:>4} [{text}]") def cmd_lint(args) -> None: ps = packs() codes = [c.upper() for c in args.codes] or sorted(ps) bad = 0 for code in codes: if code not in ps: print(f"{code}: not installed") bad += 1 continue errs, warns = lint(ps[code]) for w in warns: print(f"{code} WARN {w}") for e in errs: print(f"{code} ERROR {e}") bad += bool(errs) if not errs: print(f"{code}: ok" + (f" ({len(warns)} warnings)" if warns else "")) sys.exit(1 if bad else 0) def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = ap.add_subparsers(dest="cmd", required=True) sub.add_parser("list") s = sub.add_parser("show") s.add_argument("code") li = sub.add_parser("lint") li.add_argument("codes", nargs="*") args = ap.parse_args() {"list": cmd_list, "show": cmd_show, "lint": cmd_lint}[args.cmd](args) if __name__ == "__main__": sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace") main() -
revimg.py 21.3 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = ["playwright", "pillow", "pillow-heif"] # /// """Reverse image search + Chinese keyword search: headless Chrome or Chromium opens the search engines, saves result screenshots, extracts guess text and links. revimg.py <image> [<image> ...] --out-dir rev/ [--engines baidu,yandex] reverse image search revimg.py --query "<keywords>" [--query ...] --out-dir q/ [--text-engines bing,baiduimg,sogouimg] keyword search Reverse image search: - baidu: the main engine for China. Best coverage of Chinese web pages, Weibo, Baijiahao, e-commerce and scenic-area content; gives a one-line "图中可能是…" ("the image may show…"). The "相似图片" (similar images) cards are not links; they are captured from the pcsimi requests the page sends (it scrolls a few times to get more pages), deduplicated by contsign and written to `similar` in the JSON; the first --similar-sheet of them are downloaded into a numbered contact sheet `<name>_baidu_similar.jpg` (first cell is the query image): near-duplicate photos of the same object/scene are the fastest path to a location, always open it. - yandex: supplement for buildings, street scenes and foreign content; gives "Image appears to contain" tags and source sites. - Google Lens asks for human verification from a server egress, so the script doesn't do it; when you have a browser-control tool (e.g. Claude in Chrome), search in the user's own browser, see references/search.md. Keyword search (use when general web search tools often fail on Chinese domestic content): - bing: Bing China web results (title + link). - baiduimg / sogouimg: result-page screenshots from Baidu Images and Sogou Images, for photos of similar scenes. - Baidu web search pops up a security check, not done. Each item outputs `<name>_<engine>.png` (result-page screenshot, always open it) and `.json` (guess text + links; Baidu also has `similar` and the similar-images contact sheet). <name> is the file stem; inputs that share a stem (from different folders, or differing only in case) get their number in front: 02_photo. An input that can't be read as a photo (camera RAW, a saved error page, a damaged file) is not uploaded: its `.json` records the reason, and revimg exits 2 after the other searches. Before reverse image search, use `imgprep.py variants` to make tight-crop / flipped / color-cast-removed versions and search each one — no hit on the full image is normal. Requires Google Chrome or Playwright Chromium. Chrome is tried first, then Chromium. Run `uvx playwright install chromium` if neither is installed; `doctor.py` checks that the browser can start. Examples: revimg.py photo.jpg --out-dir rev/ revimg.py v/left_crop.jpg v/left_flip.jpg --out-dir rev/ --engines baidu revimg.py photo.jpg --out-dir rev/ --engines yandex revimg.py --query "蓝色拱形顶棚 人行天桥" --query "<city> 出租车 颜色" --out-dir q/ (queries in Chinese: blue arched canopy footbridge; <city> taxi color) """ from __future__ import annotations import argparse from _browser import launch_browser from _image import ImageReadError, open_image, upright, write_working_copy from _net import fetch_bytes, PROXY_HELP import asyncio import io import json import os import sys from collections import Counter from concurrent.futures import ThreadPoolExecutor from pathlib import Path from urllib.parse import parse_qs, urlparse OWN = {"baidu": ("baidu.com", "bdimg.com", "bdstatic.com"), "yandex": ("yandex.", "ya.ru", "yastatic.net"), "bing": ("bing.com", "bing.net", "microsoft.com"), "baiduimg": ("baidu.com", "bdimg.com", "bdstatic.com"), "sogouimg": ("sogou.com", "sogoucdn.com")} BAIDU_REFUSED = ("功能优化中", "建议您重新上传其他图片") TEXT_URL = {"bing": "https://cn.bing.com/search?q={q}", "baiduimg": "https://image.baidu.com/search/index?tn=baiduimage&word={q}", "sogouimg": "https://pic.sogou.com/pics?query={q}"} UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36" async def _collect(page, engine: str) -> dict: text = await page.evaluate("document.body ? document.body.innerText : ''") links = await page.evaluate("""() => [...document.querySelectorAll('a[href^="http"]')] .map(a => ({t: (a.innerText || a.title || '').trim().replace(/\\s+/g, ' ').slice(0, 120), h: a.href})) .filter(x => x.t.length >= 4)""") seen, out = set(), [] for ln in links: host = urlparse(ln["h"]).netloc if any(o in host for o in OWN[engine]) or ln["h"] in seen: continue seen.add(ln["h"]) out.append({"title": ln["t"], "url": ln["h"], "site": host}) lines = [s.strip() for s in text.splitlines() if s.strip()] guess = [s for s in lines if s.startswith("图中可能是") or "appears to contain" in s.lower()] if engine == "yandex" and guess: # the tags are the few lines right after "appears to contain" i = lines.index(guess[0]) guess = [guess[0] + ": " + " / ".join(lines[i + 1:i + 8])] return {"url": page.url, "guess": guess, "links": out[:25], "text_head": "\n".join(lines[:60])[:2500]} def _site(url: str) -> str: host = urlparse(url).netloc.lower().split(":")[0] for pre in ("www.", "m.", "wap.", "mobile."): if host.startswith(pre) and host.count(".") > 1: return host[len(pre):] return host def _font(size: int): from PIL import ImageFont for p in ("/System/Library/Fonts/STHeiti Medium.ttc", "/System/Library/Fonts/Hiragino Sans GB.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"): try: return ImageFont.truetype(p, size) except Exception: # noqa: BLE001 continue return ImageFont.load_default() def _similar_sheet(query: Path, items: list[dict], out: Path, cols: int = 5, tile: int = 300, proxy: str | None = None) -> int: """Query image + thumbnails of the first N similar images → numbered contact sheet (number = index in the similar array). Returns how many downloaded successfully.""" from PIL import Image, ImageDraw def fetch(url: str): try: data = fetch_bytes(url, proxy, 15, {"User-Agent": UA, "Referer": "https://graph.baidu.com/"}) return Image.open(io.BytesIO(data)).convert("RGB") except Exception: # noqa: BLE001 return None with ThreadPoolExecutor(8) as ex: ims = list(ex.map(fetch, [it["thumb"] for it in items])) try: q = upright(open_image(query)).convert("RGB") except ImageReadError: q = None tiles = [(q, "query", "cyan")] + [ (im, f"{i:02d} {it['site'] or '?'}", "yellow") for i, (it, im) in enumerate(zip(items, ims))] bar = 24 rows = (len(tiles) + cols - 1) // cols S = Image.new("RGB", (cols * tile, rows * (tile + bar)), (40, 40, 40)) d = ImageDraw.Draw(S) f = _font(17) for k, (im, label, color) in enumerate(tiles): x, y = (k % cols) * tile, (k // cols) * (tile + bar) d.rectangle([x, y, x + tile - 1, y + bar - 1], fill="black") d.text((x + 4, y + 2), label[:30], fill=color, font=f) if im is None: d.text((x + 10, y + bar + tile // 2), "download failed", fill="gray", font=f) continue im.thumbnail((tile - 4, tile - 4)) S.paste(im, (x + (tile - im.width) // 2, y + bar + (tile - im.height) // 2)) S.save(out, quality=88) return sum(im is not None for im in ims) async def _baidu(ctx, img: Path, shot: Path, similar_pages: int = 2, sheet_n: int = 24, proxy: str | None = None) -> dict: page = await ctx.new_page() simi: list = [] # "相似图片" (similar images) cards aren't DOM links; they come from ajax/pcsimi responses async def grab(resp): try: data = (await resp.json()).get("data") or {} simi.append((int(parse_qs(urlparse(resp.url).query).get("page", ["0"])[0] or 0), data.get("list") or [])) except Exception: # noqa: BLE001 simi.append((0, [])) pending: list = [] page.on("response", lambda r: pending.append(asyncio.ensure_future(grab(r))) if "graph.baidu.com/ajax/pcsimi" in r.url else None) await page.goto("https://graph.baidu.com/pcpage/index?tpl_from=pc", wait_until="domcontentloaded", timeout=60000) await page.wait_for_timeout(2000) inp = await page.query_selector("input[type=file]") if not inp: await page.screenshot(path=str(shot)) return {"error": "upload input not found (page redesigned or blocked); check the screenshot"} await inp.set_input_files(str(img)) for _ in range(60): await page.wait_for_timeout(500) if "graph.baidu.com/s" in page.url: break await page.wait_for_timeout(4500) await page.screenshot(path=str(shot), full_page=True, clip={"x": 0, "y": 0, "width": 1400, "height": 3200}) res = await _collect(page, "baidu") misses, got = 0, 0 # scrolling to the bottom triggers the next page; stop after two misses in a row while got < similar_pages and misses < 2 and pending: n0 = len(pending) await page.evaluate("window.scrollTo(0, document.documentElement.scrollHeight)") for _ in range(16): await page.wait_for_timeout(250) if len(pending) > n0: break got, misses = (got + 1, 0) if len(pending) > n0 else (got, misses + 1) await asyncio.gather(*pending) await page.close() seen, similar = set(), [] for _, lst in sorted(simi, key=lambda t: t[0]): for it in lst: key = it.get("contsign") or it.get("thumbUrl") if not it.get("thumbUrl") or key in seen: continue seen.add(key) similar.append({"thumb": it["thumbUrl"], "from": it.get("fromUrl") or "", "site": _site(it.get("fromUrl") or "")}) res["similar"] = similar if similar and sheet_n > 0: sheet = shot.with_name(shot.stem + "_similar.jpg") ok = await asyncio.to_thread(_similar_sheet, img, similar[:sheet_n], sheet, proxy=proxy) res["similar_sheet"] = str(sheet) if ok < min(sheet_n, len(similar)): res["similar_sheet_note"] = f"{min(sheet_n, len(similar)) - ok} thumbnails failed to download" if not res["guess"] and any(w in res["text_head"] for w in BAIDU_REFUSED): res["error"] = "Baidu refused to process this image (the \"功能优化中\" (\"under optimization\") page); this does not count as searched with no result: try another crop, retry later, or use only Yandex" return res async def _yandex(ctx, img: Path, shot: Path) -> dict: page = await ctx.new_page() await page.goto("https://yandex.com/images/search?rpt=imageview", wait_until="domcontentloaded", timeout=60000) await page.wait_for_timeout(2500) inp = await page.query_selector("input[type=file]") if not inp: await page.screenshot(path=str(shot)) return {"error": "upload input not found (possibly asked for verification); check the screenshot"} await inp.set_input_files(str(img)) for _ in range(60): await page.wait_for_timeout(500) if "url=" in page.url or "cbir_id" in page.url: break await page.wait_for_timeout(4500) for label in ("Allow essential cookies", "Only essential", "Accept essential", "Allow all", "Accept all"): try: # the cookie popup covers the bottom right of the screenshot btn = page.get_by_role("button", name=label) if await btn.count(): await btn.first.click(timeout=2000) await page.wait_for_timeout(800) break except Exception: # noqa: BLE001 continue await page.screenshot(path=str(shot), full_page=True, clip={"x": 0, "y": 0, "width": 1400, "height": 3200}) res = await _collect(page, "yandex") await page.close() return res async def _text(ctx, engine: str, query: str, shot: Path) -> dict: from urllib.parse import quote page = await ctx.new_page() await page.goto(TEXT_URL[engine].format(q=quote(query)), wait_until="domcontentloaded", timeout=60000) await page.wait_for_timeout(3500) head = await page.evaluate("document.body ? document.body.innerText.slice(0, 400) : ''") if "安全验证" in head or "captcha" in page.url.lower(): await page.screenshot(path=str(shot)) await page.close() return {"error": "asked for human verification (don't bypass it); switch to another engine"} await page.screenshot(path=str(shot), full_page=True, clip={"x": 0, "y": 0, "width": 1400, "height": 3000}) if engine == "bing": rows = await page.evaluate("""() => [...document.querySelectorAll('li.b_algo')].map(li => ({ t: (li.querySelector('h2') || {}).innerText || '', h: (li.querySelector('h2 a') || {}).href || '', site: (li.querySelector('cite') || {}).innerText || '', s: ((li.querySelector('.b_caption p, .b_lineclamp2, .b_lineclamp3, .b_paractl') || {}).innerText || '').slice(0, 200)}))""") links = [{"title": r["t"].strip(), "url": r["h"], "site": r["site"].strip()[:80], "snippet": r["s"].strip()} for r in rows if r["t"]] res = {"url": page.url, "guess": [], "links": links[:20], "text_head": ""} else: res = await _collect(page, engine) await page.close() return res def _names(images: list[Path]) -> list[str]: """One name per input for its screenshots, result JSON and upload copy. A stem shared with another input (from another folder, or differing only in case, which Windows and macOS file systems don't tell apart) gets the input's number in front; the result is unique without regard to case.""" stems = [i.stem.casefold() for i in images] names, used = [], set() for k, img in enumerate(images): name = img.stem if stems.count(img.stem.casefold()) == 1 else f"{k + 1:02d}_{img.stem}" while name.casefold() in used: name = f"{k + 1:02d}_{name}" used.add(name.casefold()) names.append(name) return names def _upload_path(img: Path, out_dir: Path, name: str | None = None) -> Path: """Search engines take JPEG and PNG as stored. A HEIC, WebP, ... or a photo with an EXIF orientation still to apply is uploaded as an upright lossless PNG copy, out_dir/upload/<name>.png; everything else as it is. Raises ImageReadError for a file the shared loader rejects: it is never uploaded.""" info = write_working_copy(img, out_dir / "upload" / f"{name or img.stem}.png") return Path(info["working"]) if info else img async def run(images: list[Path], engines: list[str], out_dir: Path, proxy: str | None, queries: list[str] | None = None, text_engines: list[str] | None = None, similar_pages: int = 2, sheet_n: int = 24) -> list[dict]: out_dir.mkdir(parents=True, exist_ok=True) results = [] names = _names(images) uploads: list[Path | None] = [] for img, name in zip(images, names) if engines else []: try: uploads.append(_upload_path(img, out_dir, name)) except ImageReadError as e: # reported once per engine, the way a failed search is, but nothing is sent uploads.append(None) for eng in engines: res = {"error": f"cannot read image: {e.reason}", "unreadable": e.category, "image": str(img), "engine": eng, "guess": [], "links": []} (out_dir / f"{name}_{eng}.json").write_text(json.dumps(res, ensure_ascii=False, indent=1), encoding="utf-8") results.append(res) if not queries and not any(uploads): return results from playwright.async_api import async_playwright async with async_playwright() as p: async def browser(): result, _ = await launch_browser(p, proxy) return result if queries: b = await browser() ctx = await b.new_context(viewport={"width": 1400, "height": 1000}, locale="zh-CN") for k, q in enumerate(queries): for eng in text_engines or []: shot = out_dir / f"q{k + 1:02d}_{eng}.png" try: res = await _text(ctx, eng, q, shot) except Exception as e: # noqa: BLE001 res = {"error": str(e)[:300]} res.update({"query": q, "engine": eng, "screenshot": str(shot), "image": f"q{k + 1:02d}"}) (out_dir / f"q{k + 1:02d}_{eng}.json").write_text(json.dumps(res, ensure_ascii=False, indent=1), encoding="utf-8") results.append(res) await b.close() for eng in engines if any(uploads) else []: b = await browser() ctx = await b.new_context(viewport={"width": 1400, "height": 1000}, locale="zh-CN" if eng == "baidu" else "en-US") for img, name, up in zip(images, names, uploads): if up is None: continue shot = out_dir / f"{name}_{eng}.png" try: res = await (_baidu(ctx, up, shot, similar_pages, sheet_n, proxy) if eng == "baidu" else _yandex(ctx, up, shot)) except Exception as e: # noqa: BLE001 res = {"error": str(e)[:300]} res.update({"image": str(img), "engine": eng, "screenshot": str(shot)}) if up != img: res["uploaded"] = str(up) (out_dir / f"{name}_{eng}.json").write_text(json.dumps(res, ensure_ascii=False, indent=1), encoding="utf-8") results.append(res) await b.close() return results def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("images", nargs="*", type=Path) ap.add_argument("--out-dir", type=Path, required=True) ap.add_argument("--engines", default="baidu,yandex", help="reverse image search engines") ap.add_argument("--query", action="append", help="keyword search, repeatable") ap.add_argument("--text-engines", default="bing,baiduimg,sogouimg", help="keyword search engines") ap.add_argument("--proxy", default=os.environ.get("GEO_PROXY"), help=PROXY_HELP) ap.add_argument("--exclude", help="comma-separated exclude words: results whose title contains them are not listed (e.g. to hide the puzzle's source in blind tests)") ap.add_argument("--similar-pages", type=int, default=2, help="how many more pages of Baidu similar images to load by scrolling past the first (about 30 per page)") ap.add_argument("--similar-sheet", type=int, default=24, help="how many of the top similar images to download into a contact sheet; 0 = no sheet") args = ap.parse_args() excl = [w for w in (args.exclude or "").split(",") if w] if not args.images and not args.query: ap.error("give image paths for reverse image search, or use --query for keyword search") missing = [str(p) for p in args.images if not p.is_file()] if missing: ap.error("image not found: " + ", ".join(missing)) engines = [e for e in args.engines.split(",") if e in ("baidu", "yandex")] text_engines = [e for e in args.text_engines.split(",") if e in TEXT_URL] results = asyncio.run(run(args.images, engines, args.out_dir, args.proxy, args.query, text_engines, args.similar_pages, args.similar_sheet)) for r in results: head = f"[{r['engine']}] {r['query'] if r.get('query') else Path(r['image']).name}" if r.get("error"): print(f"{head} error: {r['error']}" + (f" screenshot {r['screenshot']}" if r.get("screenshot") else "")) continue print(f"{head} screenshot {r['screenshot']}") for g in r["guess"][:2]: print(f" guess: {g[:160]}") links = [ln for ln in r["links"] if not any(w in ln["title"] for w in excl)] if excl and (len(links) < len(r["links"]) or any(w in r.get("text_head", "") for w in excl)): print(f" note: exclude words appear in the results; {len(r['links']) - len(links)} hidden; they may still be visible in the screenshot") for ln in links[:8]: print(f" - {ln['title'][:60]} ({ln['site']})") if r.get("similar"): tally = ", ".join(f"{s} {n}" for s, n in Counter(x["site"] for x in r["similar"]).most_common(6)) print(f" similar images: {len(r['similar'])} ({tally})" + (f" contact sheet {r['similar_sheet']} (open it and look for the same object/scene)" if r.get("similar_sheet") else "")) elif r["engine"] == "baidu": print(" similar images: none captured") unreadable = {r["image"]: r["error"].removeprefix("cannot read image: ") for r in results if r.get("unreadable")} if unreadable: print(f"revimg: {len(unreadable)} of {len(args.images)} images could not be read and were not uploaded: " + "; ".join(f"{p} ({why})" for p, why in unreadable.items()), file=sys.stderr) sys.exit(2) if __name__ == "__main__": # Chinese-locale Windows writes GBK by default: m², ñ make it crash, and the Chinese the agent reads comes out garbled sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace") main() -
sat_scan.py 16 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = ["pillow", "numpy", "torch", "transformers", "socksio", "pysocks", "requests"] # /// """Find a class of target on satellite imagery (running tracks, stadiums, factory buildings, silos, dams, bridges, open-pit mines, solar farms, greenhouses, ports): split an area into a grid, score each cell with CLIP zero-shot and sort; you look only at the top 20–30 instead of paging through dozens of contact-sheet pages. grid tile a bbox with a grid and score cell by cell → ranked.json + contact sheet of top-N thumbnails + score heatmap points score and rank a given list of candidate points ({name: [lat, lon]} from osm.py buildings / poi.py) point by point presets list the presets and their prompts The score is an ordering by "how alike", not a verdict: you still have to look at the thumbnails of the top few, then check them against the bearing and shape in the photo. Cells that are almost a single color (Google placeholder, no imagery) are marked blank and scored 0. --seeds pois.json: cells near seed points (school, factory POIs) get a bonus; OSM in China maps few running tracks, but 360/Baidu POIs list schools very completely (poi.py). Examples: sat_scan.py grid --bbox <s,w,n,e> --zoom 17 --cell 320 --preset track --top 30 --out ranked.json --sheet top.jpg --heat heat.jpg sat_scan.py grid --bbox <s,w,n,e> --preset factory --seeds factories.json --seed-radius 500 --out r.json --sheet r.jpg sat_scan.py points --points big_buildings.json --zoom 17 --preset silo --out r.json --sheet r.jpg sat_scan.py grid --bbox <s,w,n,e> --query "aerial view of a red running track" --neg "aerial view of houses" --out r.json """ from __future__ import annotations import argparse from _device import torch_device from _net import PROXY_HELP from _net import model_proxy_env import json import math import os import re import sys import time from concurrent.futures import ThreadPoolExecutor from pathlib import Path import numpy as np from PIL import Image, ImageDraw sys.path.insert(0, str(Path(__file__).parent)) import geo # noqa: E402 import tiles # noqa: E402 CLIP_ID = "openai/clip-vit-base-patch32" NEG_COMMON = ["a satellite image of residential buildings and streets", "a satellite image of a road intersection", "a satellite image of farmland", "a satellite image of a river", "a satellite image of a parking lot", "a satellite image of forest", "a satellite image of a highway", "a satellite image of bare ground"] PRESETS = { "track": (["a satellite image of a school running track", "an aerial view of an athletics track around a football field", "an oval red running track seen from above", "a sports ground with a red oval track and green field from above"], NEG_COMMON + ["a satellite image of a roundabout", "a satellite image of a swimming pool"]), "stadium": (["a satellite image of a large stadium", "an aerial view of a stadium with stands around a field"], NEG_COMMON), "factory": (["a satellite image of a large factory building with a long roof", "an aerial view of industrial warehouses", "an aerial view of an industrial plant with big sheds"], NEG_COMMON + ["a satellite image of apartment blocks"]), "silo": (["a satellite image of grain silos and a drying tower", "an aerial view of a rice mill with silos", "an aerial view of a farm with round silos and long sheds"], NEG_COMMON), "dam": (["a satellite image of a dam with a reservoir", "an aerial view of a hydroelectric dam"], NEG_COMMON), "bridge": (["a satellite image of a bridge crossing a river", "an aerial view of a long bridge over water"], NEG_COMMON), "quarry": (["a satellite image of an open pit mine", "an aerial view of a quarry with terraces"], NEG_COMMON), "solar": (["a satellite image of a solar farm with rows of panels"], NEG_COMMON), "greenhouse": (["a satellite image of rows of greenhouses", "an aerial view of plastic greenhouses"], NEG_COMMON), "port": (["a satellite image of a port with container cranes", "an aerial view of a harbor with ships and piers"], NEG_COMMON), "school": (["a satellite image of a school campus with a running track and classroom buildings"], NEG_COMMON), } def _proxy_env(proxy: str | None) -> None: model_proxy_env(proxy) def cell_image(lat: float, lon: float, zoom: int, size: int, source: str, proxy: str | None, cache: Path) -> Image.Image: """Stitch a size×size satellite image centered on (lat, lon) (reuses the tile cache).""" gx, gy = geo.ll2px(zoom, lat, lon) x0, y0 = gx - size / 2, gy - size / 2 tile = Image.new("RGB", (size, size), "gray") for tx in range(int(x0 // 256), int((x0 + size) // 256) + 1): for ty in range(int(y0 // 256), int((y0 + size) // 256) + 1): p = cache / f"{source}_{zoom}_{tx}_{ty}.jpg" if tiles._get(tiles.SOURCES[source].format(x=tx, y=ty, z=zoom), p, proxy): try: tile.paste(Image.open(p), (int(tx * 256 - x0), int(ty * 256 - y0))) except Exception: # noqa: BLE001 pass return tile def prefetch(points: dict, zoom: int, size: int, source: str, proxy: str | None, cache: Path) -> None: need = set() for lat, lon in points.values(): gx, gy = geo.ll2px(zoom, lat, lon) x0, y0 = gx - size / 2, gy - size / 2 for tx in range(int(x0 // 256), int((x0 + size) // 256) + 1): for ty in range(int(y0 // 256), int((y0 + size) // 256) + 1): need.add((tx, ty)) cache.mkdir(parents=True, exist_ok=True) with ThreadPoolExecutor(16) as ex: list(ex.map(lambda t: tiles._get(tiles.SOURCES[source].format(x=t[0], y=t[1], z=zoom), cache / f"{source}_{zoom}_{t[0]}_{t[1]}.jpg", proxy), need)) CLIPN = ([0.48145466, 0.4578275, 0.40821073], [0.26862954, 0.26130258, 0.27577711]) def _tensor(ims: list[Image.Image], torch): mean, std = (np.array(v, dtype=np.float32).reshape(1, 3, 1, 1) for v in CLIPN) arr = np.stack([np.asarray(im.convert("RGB"), dtype=np.float32) / 255.0 for im in ims]).transpose(0, 3, 1, 2) return torch.from_numpy((arr - mean) / std) def _feat(out): """In transformers 5, get_*_features returns BaseModelOutputWithPooling with the projected vector in pooler_output; older versions return the tensor directly.""" if hasattr(out, "shape"): return out for k in ("pooler_output", "text_embeds", "image_embeds"): v = getattr(out, k, None) if v is not None and hasattr(v, "shape"): return v return out[0] class Scorer: def __init__(self, pos: list[str], neg: list[str]): import torch from transformers import CLIPModel, CLIPTokenizer self.dev = torch_device(torch) t0 = time.time() try: tok = CLIPTokenizer.from_pretrained(CLIP_ID) self.model = CLIPModel.from_pretrained(CLIP_ID).to(self.dev).eval() except Exception as e: # noqa: BLE001 sys.exit(f"failed to load CLIP: {str(e)[:300]}\ncheck disk space, model availability and `doctor.py --network`.") self.torch = torch with torch.no_grad(): t = tok(pos + neg, return_tensors="pt", padding=True).to(self.dev) tf = _feat(self.model.get_text_features(**t)) self.text = (tf / tf.norm(dim=1, keepdim=True)).float() self.npos = len(pos) print(f"CLIP ready ({self.dev}, {time.time() - t0:.1f}s), {len(pos)} positive prompts, {len(neg)} negative prompts", file=sys.stderr) def score(self, ims: list[Image.Image], multi_scale: bool, batch: int = 48) -> np.ndarray: """Score per image = sum of positive-prompt probabilities (softmax over all prompts, logit scale 100); multi_scale also looks at 2×2 sub-blocks and takes the max.""" views_per = 5 if multi_scale else 1 flat = [] for im in ims: im = im.convert("RGB") flat.append(im.resize((224, 224), Image.BICUBIC)) if multi_scale: w, h = im.size for (a, b) in ((0, 0), (w // 2, 0), (0, h // 2), (w // 2, h // 2)): flat.append(im.crop((a, b, a + w // 2, b + h // 2)).resize((224, 224), Image.BICUBIC)) probs = [] with self.torch.no_grad(): for i in range(0, len(flat), batch): x = _tensor(flat[i:i + batch], self.torch).to(self.dev) f = _feat(self.model.get_image_features(pixel_values=x)) f = f / f.norm(dim=1, keepdim=True) logits = 100.0 * f.float() @ self.text.T p = logits.softmax(dim=1)[:, : self.npos].sum(dim=1) probs.append(p.cpu().numpy()) arr = np.concatenate(probs).reshape(len(ims), views_per) return arr.max(axis=1) def _blank(im: Image.Image) -> bool: a = np.asarray(im.convert("L").resize((64, 64)), dtype=np.float32) return float(a.std()) < 6.0 def _sheet(rows: list[dict], ims: list[Image.Image], out: Path, cols: int, size: int) -> list[Path]: from baidu_pano import _font f = _font(14) per = cols * cols pages = [] for pi in range(0, len(rows), per): chunk = rows[pi:pi + per] S = Image.new("RGB", (cols * size, ((len(chunk) + cols - 1) // cols) * size), "black") d = ImageDraw.Draw(S) for k, r in enumerate(chunk): cx, cy = (k % cols) * size, (k // cols) * size S.paste(ims[r["_i"]].resize((size, size)), (cx, cy)) c = size / 2 d.line([cx + c - 10, cy + c, cx + c + 10, cy + c], fill="red", width=2) d.line([cx + c, cy + c - 10, cx + c, cy + c + 10], fill="red", width=2) d.rectangle([cx, cy, cx + size, cy + 20], fill="black") d.text((cx + 4, cy + 3), f"#{r['rank']} {r['score']:.2f} {r['lat']:.5f},{r['lon']:.5f}" + (" seed" if r.get("seed_near") else ""), fill="yellow", font=f) o = out if pi == 0 else out.with_name(f"{out.stem}_{pi // per + 1}{out.suffix}") S.save(o, quality=88) pages.append(o) return pages def _heat(rows: list[dict], names: list[str], out: Path) -> None: """Lay the cells out as a matrix by cell name rXXcYY, mapping score to color.""" rc = {} for r in rows: m = re.match(r"r(\d+)c(\d+)", r["cell"]) if m: rc[(int(m.group(1)), int(m.group(2)))] = r["score"] if not rc: return R = max(k[0] for k in rc) + 1 C = max(k[1] for k in rc) + 1 px = 24 S = Image.new("RGB", (C * px, R * px), "black") d = ImageDraw.Draw(S) mx = max(rc.values()) or 1 for (i, j), s in rc.items(): v = s / mx d.rectangle([j * px, i * px, (j + 1) * px - 1, (i + 1) * px - 1], fill=(int(255 * v), int(120 * v), int(255 * (1 - v)) // 3)) S.save(out) def run(points: dict, args) -> None: _proxy_env(args.proxy) cache = Path(args.cache) if args.preset: pos, neg = PRESETS[args.preset] else: pos, neg = [], list(NEG_COMMON) pos = list(pos) + list(args.query or []) neg = list(neg) + list(args.neg or []) if not pos: sys.exit("give --preset or --query") t0 = time.time() prefetch(points, args.zoom, args.size, args.source, args.proxy, cache) names = list(points) with ThreadPoolExecutor(8) as ex: ims = list(ex.map(lambda n: cell_image(points[n][0], points[n][1], args.zoom, args.size, args.source, args.proxy, cache), names)) t1 = time.time() sc = Scorer(pos, neg) scores = sc.score(ims, args.multi_scale) t2 = time.time() seeds = json.loads(Path(args.seeds).read_text(encoding="utf-8")) if args.seeds else {} rows = [] for i, n in enumerate(names): lat, lon = points[n] blank = _blank(ims[i]) s = 0.0 if blank else float(scores[i]) near = None if seeds: for sn, sl in seeds.items(): if geo.distance((lat, lon), (sl[0], sl[1])) <= args.seed_radius: near = sn s += args.seed_bonus break rows.append({"cell": n, "lat": lat, "lon": lon, "score": round(s, 4), "blank": blank, "seed_near": near, "_i": i}) rows.sort(key=lambda r: -r["score"]) for k, r in enumerate(rows): r["rank"] = k + 1 print(f"{len(names)} cells; fetch {t1 - t0:.1f}s, scoring {t2 - t1:.1f}s; blank cells {sum(1 for r in rows if r['blank'])}") print(f"{'#':>3} {'score':>6} cell/name lat,lon") for r in rows[: min(args.top, 20)]: print(f"{r['rank']:>3} {r['score']:>6.3f} {str(r['cell'])[:18]:<18} {r['lat']:.5f},{r['lon']:.5f}" + (f" near {r['seed_near']}" if r["seed_near"] else "")) if args.out: Path(args.out).write_text(json.dumps([{k: v for k, v in r.items() if k != "_i"} for r in rows], ensure_ascii=False, indent=1), encoding="utf-8") print(f"-> {args.out}") pts = {f"#{r['rank']} {r['score']:.2f}": [r["lat"], r["lon"]] for r in rows[: args.top]} Path(args.out).with_suffix(".top.json").write_text(json.dumps(pts, ensure_ascii=False, indent=1), encoding="utf-8") if args.sheet: for p in _sheet(rows[: args.top], ims, Path(args.sheet), args.cols, args.size): print(f"-> {p}") if getattr(args, "heat", None): _heat(rows, names, Path(args.heat)) print(f"-> {args.heat} (rows north to south, columns west to east; brighter = more alike)") def _neg_coords(argv: list[str]) -> list[str]: return [" " + a if re.match(r"^-\d[\d.]*(,-?[\d.]+)+$", a) else a for a in argv] def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = ap.add_subparsers(dest="cmd", required=True) def common(sp): sp.add_argument("--preset", choices=list(PRESETS)) sp.add_argument("--query", action="append", help="custom positive prompt (English), repeatable") sp.add_argument("--neg", action="append", help="custom negative prompt, repeatable") sp.add_argument("--zoom", type=int, default=17) sp.add_argument("--size", "--cell", dest="size", type=int, default=320, help="pixels per cell") sp.add_argument("--multi-scale", action="store_true", help="also look at 2×2 sub-blocks of each cell and take the max (for small targets)") sp.add_argument("--seeds", help="{name:[lat,lon]} seed points; nearby cells get a bonus") sp.add_argument("--seed-radius", type=float, default=400) sp.add_argument("--seed-bonus", type=float, default=0.15) sp.add_argument("--top", type=int, default=30) sp.add_argument("--cols", type=int, default=5) sp.add_argument("--out") sp.add_argument("--sheet") sp.add_argument("--source", choices=list(tiles.SOURCES), default="google") sp.add_argument("--proxy", default=os.environ.get("GEO_PROXY"), help=PROXY_HELP) sp.add_argument("--cache", type=Path, default=Path(".geo-cache/tiles")) g = sub.add_parser("grid") g.add_argument("--bbox", required=True, help="s,w,n,e") g.add_argument("--step", type=float, help="cell spacing in meters; default is 90%% of the width each cell covers") g.add_argument("--heat", help="score heatmap output") common(g) p = sub.add_parser("points") p.add_argument("--points", required=True, help="{name: [lat, lon]}") common(p) sub.add_parser("presets") args = ap.parse_args(_neg_coords(sys.argv[1:])) if args.cmd == "presets": for k, (pos, neg) in PRESETS.items(): print(f"{k}:\n + " + "\n + ".join(pos) + f"\n - {len(neg)} negative prompts") return if args.cmd == "grid": pts = tiles.grid_points(args.bbox, args.zoom, args.size, args.step) print(f"grid: {len(pts)} cells (r rows north to south, c columns west to east)") else: pts = json.loads(Path(args.points).read_text(encoding="utf-8")) run(pts, args) if __name__ == "__main__": # Chinese-locale Windows writes GBK by default: m², ñ make it crash, and the Chinese the agent reads comes out garbled sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace") main() -
sun.py 30.4 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = ["pillow"] # /// """Sky geometry: sun position, shadows, satellite TV dishes. Sun position uses the NOAA algorithm (Meeus), error about 0.01° for 1950–2050, with atmospheric refraction correction. Azimuths are always compass bearings: 0=north, clockwise. Times can carry a time zone (--tz Asia/Shanghai); the default is UTC. pos sun azimuth, elevation angle, shadow length of a 1 m object, and shadow bearing at a given place and time ratio shadow ratio ↔ sun elevation angle conversion (object height 1, shadow length r) locate known capture time + measured elevation angle (or shadow ratio) / shadow bearing → find the matching band within an area when known place + elevation angle / shadow bearing → work back to which times of day (or which days in a date range) match dish geostationary satellite dish pointing; given the dish azimuth it can also work back to longitude street known place and date + angle between shadow and street → street orientation candidates (morning and afternoon listed separately) facing which walls are lit and which are shaded → camera heading range (use when there is no measurable shadow) compass sun in the frame → camera heading, and the true bearing of any object in the frame (with no time given, split into sunrise and sunset groups) Examples: sun.py pos --at 39.9042,116.4074 --time 2023-08-15T16:20 --tz Asia/Shanghai sun.py ratio --shadow 1.2 sun.py locate --time 2023-08-15T16:20 --tz Asia/Shanghai --ratio 1.2 --tol 1.5 --bbox 34,110,42,122 sun.py locate --time 2023-08-15T16:20 --tz Asia/Shanghai --elev 40 --shadow-bearing 60 --az-tol 10 \ --bbox 34,110,42,122 --mosaic north.jpg --out band.jpg sun.py when --at 30.25,120.16 --date 2024-10-01 --tz Asia/Shanghai --ratio 1.2 --shadow-bearing 30 sun.py when --at 30.25,120.16 --dates 2024-01-01:2024-12-31 --tz Asia/Shanghai --elev 40 --shadow-bearing 330 sun.py dish --at 31.23,121.47 --sat 92.2 sun.py dish --lat 31.2 --sat 92.2 --azimuth 215 # dish faces 215°, work back to longitude sun.py compass --at <lat,lon> --time 07:40 --dates 2024-09-01:2024-10-15 --tz <IANA time zone> --sun-x 1200 --width 4000 --hfov 60:70 --x 2900 sun.py compass --at <lat,lon> --tz <IANA time zone> --sun-x 2600 --width 4032 --x 1500 # no time, no date: morning and evening groups over the whole year sun.py street --at 49.25,-123.10 --date 2025-04-01 --tz America/Vancouver --ratio 1.5 --tol 4 --shadow-rel 90 """ from __future__ import annotations import argparse import json import math import re import sys from datetime import date, datetime, timedelta, timezone from pathlib import Path from zoneinfo import ZoneInfo sys.path.insert(0, str(Path(__file__).parent)) # ---------------------------------------------------------------- Sun position _D = math.degrees _R = math.radians def _sun_core(dt_utc: datetime) -> tuple[float, float]: """Returns (solar declination °, equation of time in minutes).""" jd = dt_utc.timestamp() / 86400 + 2440587.5 t = (jd - 2451545) / 36525 l0 = (280.46646 + t * (36000.76983 + t * 0.0003032)) % 360 m = 357.52911 + t * (35999.05029 - 0.0001537 * t) e = 0.016708634 - t * (0.000042037 + 0.0000001267 * t) c = (math.sin(_R(m)) * (1.914602 - t * (0.004817 + 0.000014 * t)) + math.sin(_R(2 * m)) * (0.019993 - 0.000101 * t) + math.sin(_R(3 * m)) * 0.000289) app_long = l0 + c - 0.00569 - 0.00478 * math.sin(_R(125.04 - 1934.136 * t)) obliq0 = 23 + (26 + (21.448 - t * (46.815 + t * (0.00059 - t * 0.001813))) / 60) / 60 obliq = obliq0 + 0.00256 * math.cos(_R(125.04 - 1934.136 * t)) decl = _D(math.asin(math.sin(_R(obliq)) * math.sin(_R(app_long)))) y = math.tan(_R(obliq / 2)) ** 2 eqt = 4 * _D(y * math.sin(2 * _R(l0)) - 2 * e * math.sin(_R(m)) + 4 * e * y * math.sin(_R(m)) * math.cos(2 * _R(l0)) - 0.5 * y * y * math.sin(4 * _R(l0)) - 1.25 * e * e * math.sin(2 * _R(m))) return decl, eqt def _refraction(h: float) -> float: if h > 85: return 0.0 te = math.tan(_R(h)) if h > 5: r = 58.1 / te - 0.07 / te ** 3 + 0.000086 / te ** 5 elif h > -0.575: r = 1735 + h * (-518.2 + h * (103.4 + h * (-12.79 + h * 0.711))) else: r = -20.772 / te return r / 3600 def sun_position(lat: float, lon: float, dt_utc: datetime, refraction: bool = True) -> tuple[float, float]: """(azimuth °, elevation angle °). dt_utc must carry the UTC time zone.""" decl, eqt = _sun_core(dt_utc) minutes = dt_utc.hour * 60 + dt_utc.minute + dt_utc.second / 60 + dt_utc.microsecond / 6e7 tst = (minutes + eqt + 4 * lon) % 1440 ha = tst / 4 + 180 if tst / 4 < 0 else tst / 4 - 180 cz = math.sin(_R(lat)) * math.sin(_R(decl)) + math.cos(_R(lat)) * math.cos(_R(decl)) * math.cos(_R(ha)) zen = _D(math.acos(max(-1.0, min(1.0, cz)))) elev = 90 - zen denom = math.cos(_R(lat)) * math.sin(_R(zen)) if abs(denom) < 1e-9: az = 180.0 else: ca = max(-1.0, min(1.0, (math.sin(_R(lat)) * math.cos(_R(zen)) - math.sin(_R(decl))) / denom)) az = (_D(math.acos(ca)) + 180) % 360 if ha > 0 else (540 - _D(math.acos(ca))) % 360 if refraction: elev += _refraction(elev) return az, elev def subsolar_point(dt_utc: datetime) -> tuple[float, float]: """Subsolar point (lat, lon). At a given moment, the points with the same sun elevation angle form a circle centered on it with radius 90°-elevation angle.""" decl, eqt = _sun_core(dt_utc) minutes = dt_utc.hour * 60 + dt_utc.minute + dt_utc.second / 60 lon = -(minutes + eqt - 720) / 4 return decl, (lon + 540) % 360 - 180 def shadow_ratio(elev: float) -> float: """Shadow length (m) of a 1 m vertical object on level ground.""" return math.inf if elev <= 0 else 1 / math.tan(_R(elev)) def elev_from_ratio(ratio: float) -> float: return _D(math.atan(1 / ratio)) def _ang_diff(a: float, b: float) -> float: return abs((a - b + 180) % 360 - 180) # ---------------------------------------------------------------- Satellite dishes _RE, _RGEO = 6378.137, 42164.0 def dish_pointing(lat: float, lon: float, sat_lon: float) -> tuple[float, float]: """Ground point (lat, lon) pointing at the geostationary satellite at longitude sat_lon: (azimuth °, elevation angle °).""" la, lo = _R(lat), _R(lon) gx, gy, gz = _RE * math.cos(la) * math.cos(lo), _RE * math.cos(la) * math.sin(lo), _RE * math.sin(la) sx, sy, sz = _RGEO * math.cos(_R(sat_lon)), _RGEO * math.sin(_R(sat_lon)), 0.0 dx, dy, dz = sx - gx, sy - gy, sz - gz east = -math.sin(lo) * dx + math.cos(lo) * dy north = -math.sin(la) * math.cos(lo) * dx - math.sin(la) * math.sin(lo) * dy + math.cos(la) * dz up = math.cos(la) * math.cos(lo) * dx + math.cos(la) * math.sin(lo) * dy + math.sin(la) * dz return (_D(math.atan2(east, north)) + 360) % 360, _D(math.atan2(up, math.hypot(east, north))) # Common satellites (longitude, east positive) SATELLITES = { "chinasat9": (92.2, "ChinaSat-9 (中星9号): small dishes of China's 户户通/村村通 (rural direct-to-home) programs, the most common nationwide"), "chinasat6b": (115.5, "ChinaSat-6B: large dishes at cable TV headends and organizations"), "asiasat7": (105.5, "AsiaSat 7"), "apstar6c": (134.0, "APStar-6C"), "astra1": (19.2, "Astra 1: Germany, Austria and other Central Europe"), "hotbird": (13.0, "Hot Bird: Italy, Poland, etc."), "astra2": (28.2, "Astra 2: UK, Ireland"), "eutelsat5w": (-5.0, "Eutelsat 5W: France, parts of Spain"), "nilesat": (-7.0, "Nilesat: Middle East, North Africa"), "turksat": (42.0, "Türksat: Turkey"), } # ---------------------------------------------------------------- Input parsing def _pair(s: str) -> tuple[float, float]: a, b = s.split(",") return float(a), float(b) def _to_utc(s: str, tz: str | None) -> datetime: dt = datetime.fromisoformat(s) if dt.tzinfo is None: dt = dt.replace(tzinfo=ZoneInfo(tz) if tz else timezone.utc) return dt.astimezone(timezone.utc) def _target_elev(args) -> float | None: if getattr(args, "ratio", None) is not None: return elev_from_ratio(args.ratio) return getattr(args, "elev", None) def _target_az(args) -> float | None: """Normalize to sun azimuth. Shadow bearing = sun azimuth + 180.""" if getattr(args, "shadow_bearing", None) is not None: return (args.shadow_bearing + 180) % 360 return getattr(args, "sun_azimuth", None) # ---------------------------------------------------------------- Subcommands def cmd_pos(args) -> None: lat, lon = args.at t = _to_utc(args.time, args.tz) az, el = sun_position(lat, lon, t) ss = subsolar_point(t) print(json.dumps({ "utc": t.isoformat(timespec="minutes"), "sun_azimuth": round(az, 2), "sun_elevation": round(el, 2), "shadow_len_per_1m": None if el <= 0 else round(shadow_ratio(el), 3), "shadow_bearing": round((az + 180) % 360, 1), "subsolar_point": [round(ss[0], 3), round(ss[1], 3)], }, ensure_ascii=False)) def cmd_ratio(args) -> None: if args.shadow is not None: print(f"Shadow ratio 1:{args.shadow} → sun elevation angle {elev_from_ratio(args.shadow):.2f}°") else: print(f"Sun elevation angle {args.elev}° → shadow length of a 1 m object {shadow_ratio(args.elev):.3f} m") def _matches(lat, lon, times, tel, tol, taz, az_tol) -> bool: for t in times: az, el = sun_position(lat, lon, t) if tel is not None and abs(el - tel) > tol: continue if taz is not None and _ang_diff(az, taz) > az_tol: continue if el <= 0: continue return True return False def cmd_locate(args) -> None: tel, taz = _target_elev(args), _target_az(args) if tel is None and taz is None: sys.exit("Need at least one of --elev / --ratio or --shadow-bearing / --sun-azimuth") t0 = _to_utc(args.time, args.tz) k = max(0, int(args.time_tol // 5)) times = [t0 + timedelta(minutes=5 * i) for i in range(-k, k + 1)] if args.time_tol else [t0] s, w, n, e = args.bbox step = args.step pts = [] lat = s while lat <= n + 1e-9: lon = w while lon <= e + 1e-9: if _matches(lat, lon, times, tel, args.tol, taz, args.az_tol): pts.append((round(lat, 4), round(lon, 4))) lon += step lat += step ss = subsolar_point(t0) out = {"utc": t0.isoformat(timespec="minutes"), "target_elev": None if tel is None else round(tel, 2), "target_sun_azimuth": None if taz is None else round(taz, 1), "subsolar_point": [round(ss[0], 3), round(ss[1], 3)], "equal_elev_circle_radius_km": None if tel is None else round((90 - tel) * 111.195, 0), "grid_step_deg": step, "matched_points": len(pts)} if pts: lats, lons = [p[0] for p in pts], [p[1] for p in pts] out["matched_bbox"] = [min(lats), min(lons), max(lats), max(lons)] cols: dict[float, list[float]] = {} for la, lo in pts: cols.setdefault(round(lo, 2), []).append(la) every = max(1, len(cols) // 12) out["lat_range_by_lon"] = {f"{lo:.2f}": [min(v), max(v)] for i, (lo, v) in enumerate(sorted(cols.items())) if i % every == 0} print(json.dumps(out, ensure_ascii=False, indent=1)) if args.points: args.points.write_text(json.dumps({f"p{i}": list(p) for i, p in enumerate(pts)}), encoding="utf-8") print(f"points -> {args.points}") if args.mosaic: _draw(args.mosaic, pts, step, args.out or args.mosaic.with_name(args.mosaic.stem + "_sun.jpg")) def _draw(mosaic: Path, pts, step, out: Path) -> None: from PIL import Image, ImageDraw from tiles import Mosaic m = Mosaic(mosaic) im = Image.open(mosaic).convert("RGBA") ov = Image.new("RGBA", im.size, (0, 0, 0, 0)) d = ImageDraw.Draw(ov) for la, lo in pts: x0, y0 = m.to_px(la + step / 2, lo - step / 2) x1, y1 = m.to_px(la - step / 2, lo + step / 2) d.rectangle([x0, y0, x1, y1], fill=(255, 210, 0, 90)) Image.alpha_composite(im, ov).convert("RGB").save(out, quality=88) print(f"map -> {out}") def _scan_day(lat, lon, day: date, tz: ZoneInfo, tel, tol, taz, az_tol, step_min: int): """Returns the matching time windows in this day [(start, end, midpoint azimuth, midpoint elevation angle)].""" start = datetime(day.year, day.month, day.day, tzinfo=tz) wins, cur = [], None for i in range(0, 24 * 60, step_min): lt = start + timedelta(minutes=i) az, el = sun_position(lat, lon, lt.astimezone(timezone.utc)) ok = el > 0 and (tel is None or abs(el - tel) <= tol) and (taz is None or _ang_diff(az, taz) <= az_tol) if ok: cur = [lt, lt, []] if cur is None else [cur[0], lt, cur[2]] cur[2].append((az, el)) elif cur is not None: wins.append(cur) cur = None if cur is not None: wins.append(cur) res = [] for a, b, vals in wins: az, el = vals[len(vals) // 2] res.append((a, b, az, el)) return res def cmd_when(args) -> None: tel, taz = _target_elev(args), _target_az(args) if tel is None and taz is None: sys.exit("Need at least one of --elev / --ratio or --shadow-bearing / --sun-azimuth") tz = ZoneInfo(args.tz) if args.tz else timezone.utc lat, lon = args.at if args.dates: a, b = (date.fromisoformat(x) for x in args.dates.split(":")) days = [a + timedelta(days=i) for i in range((b - a).days + 1)] else: days = [date.fromisoformat(args.date)] hits = 0 for d in days: for s, e, az, el in _scan_day(lat, lon, d, tz, tel, args.tol, taz, args.az_tol, args.step_min): hits += 1 mid = s + (e - s) / 2 _, e1 = sun_position(lat, lon, (mid - timedelta(minutes=5)).astimezone(timezone.utc)) _, e2 = sun_position(lat, lon, (mid + timedelta(minutes=5)).astimezone(timezone.utc)) rate = abs(e2 - e1) / 10 sens = f" elevation angle changes {rate:.2f}° per minute, 1° off ≈ {1 / rate:.0f} minutes" if rate > 1e-3 else " elevation angle barely changes around noon; time resolution is poor" print(f"{d} {s:%H:%M}–{e:%H:%M} sun azimuth {az:5.1f}° elevation {el:4.1f}° shadow bearing {(az + 180) % 360:5.1f}° " f"shadow ratio 1:{shadow_ratio(el):.2f}{sens}") if not hits: print("No matching times (loosen --tol / --az-tol, or check whether the time zone or bearing is backwards)") def cmd_dish(args) -> None: sat = args.sat if args.at: lat, lon = args.at az, el = dish_pointing(lat, lon, sat) print(f"Place {lat},{lon} pointing at the satellite at longitude {sat}°E: azimuth {az:.1f}°, elevation angle {el:.1f}°") return if args.azimuth is None or args.lat is None: sys.exit("Forward calculation: give --at; working back to longitude: give --lat and --azimuth") best = [] for i in range(-1800, 1801): lon = i / 10 az, el = dish_pointing(args.lat, lon, sat) if el > 0 and _ang_diff(az, args.azimuth) <= args.az_tol: best.append((lon, az, el)) if not best: print("No longitude at this latitude matches (the dish may point at a different satellite)") return print(f"Latitude {args.lat}, dish azimuth {args.azimuth}±{args.az_tol}° to the satellite at longitude {sat}°E → longitude range {best[0][0]}° ~ {best[-1][0]}°, " f"elevation angle about {best[len(best) // 2][2]:.0f}°") def cmd_facing(args) -> None: """Which walls are lit / shaded → camera heading range. A wall is lit ⟺ |sun azimuth − wall normal| < 90°. Walls are described by "which way they face in the frame": camera=faces the camera (normal = heading+180), left=faces frame left (heading−90), right=faces frame right (heading+90), away=faces away from the camera (normal = heading). """ lat, lon = args.at t = _to_utc(args.time, args.tz) az, el = sun_position(lat, lon, t) if el <= 0: print(f"The sun is below the horizon at this time (elevation {el:.1f}°); the lit-face method doesn't apply") return offs = {"camera": 180.0, "left": -90.0, "right": 90.0, "away": 0.0} lit = [w for w in (args.lit or "").split(",") if w] shaded = [w for w in (args.shaded or "").split(",") if w] bad = [w for w in lit + shaded if w not in offs] if bad: sys.exit(f"Walls can only be camera/left/right/away, got: {bad}") ok = [] for h in range(0, 360): good = True for w in lit: if _ang_diff(az, (h + offs[w]) % 360) >= 90 - args.margin: good = False for w in shaded: if _ang_diff(az, (h + offs[w]) % 360) <= 90 + args.margin: good = False if good: ok.append(h) print(f"Sun azimuth {az:.1f}°, elevation {el:.1f}° (shadow bearing {(az + 180) % 360:.1f}°)") if not ok: print("No heading satisfies all these lit/shaded conditions: check whether you misread which wall is lit, or whether the time / time zone is wrong (the photo may also be a composite)") return runs, start = [], ok[0] for a, b in zip(ok, ok[1:] + [None]): if b is None or b != a + 1: runs.append((start, a)) start = b if len(runs) > 1 and runs[0][0] == 0 and runs[-1][1] == 359: # merge across north runs = [(runs[-1][0] - 360, runs[0][1])] + runs[1:-1] print("Possible camera heading ranges: " + ", ".join(f"{a % 360}°–{b % 360}°" for a, b in runs)) print("Tip: at the range edges the walls are nearly parallel to the sunlight and the light/dark difference is small; don't treat them as hard edges (--margin sets how conservative)") def cmd_street(args) -> None: """Measure the angle in a top-down view: clockwise from the street direction to the shadow direction. If you can't tell clockwise from counterclockwise, list both (--both).""" tz = ZoneInfo(args.tz) if args.tz else timezone.utc lat, lon = args.at tel = _target_elev(args) day = date.fromisoformat(args.date) start = datetime(day.year, day.month, day.day, tzinfo=tz) rows = [] for i in range(0, 24 * 60, args.step_min): lt = start + timedelta(minutes=i) az, el = sun_position(lat, lon, lt.astimezone(timezone.utc)) if el <= 2 or (tel is not None and abs(el - tel) > args.tol): continue shadow = (az + 180) % 360 cands = {round((shadow - args.shadow_rel) % 180)} if args.both: cands.add(round((shadow + args.shadow_rel) % 180)) rows.append((lt, az, el, shadow, sorted(cands))) if not rows: print("No matching times (loosen the elevation angle tolerance, or check the date / time zone)") return step = max(1, len(rows) // 16) for k, (lt, az, el, shadow, cands) in enumerate(rows): if k % step and k != len(rows) - 1: continue half = "morning" if lt.hour < 12 else "afternoon" print(f"{lt:%H:%M} {half} sun {az:5.1f}°/{el:4.1f}° shadow bearing {shadow:5.1f}° → street orientation " + " or ".join(f"{c}°–{c + 180}°" for c in cands)) print("Street orientation is given as 0–180° (47° means northeast–southwest). You must keep both the morning and the afternoon group, unless other evidence tells morning from afternoon.") def _px_angle(x: float, width: float, hfov: float) -> float: """Horizontal angle (°, right positive) of pixel column x relative to the frame center.""" f = (width / 2) / math.tan(math.radians(hfov) / 2) return math.degrees(math.atan((x - width / 2) / f)) def cmd_compass(args) -> None: """Sun in the frame → camera heading, and the true bearing of any pixel column in the frame (tower, chimney, intersection). Time given: use the sun azimuth at that moment directly (if the date is uncertain, give a range with --dates and the bearing becomes a range too). No time given: compute the morning and evening stretches of each day in --dates where the sun elevation is between 0 and --elev-max separately; list both the sunrise and the sunset group. """ lat, lon = args.at tz = ZoneInfo(args.tz) if args.tz else timezone.utc if args.dates: a, b = (date.fromisoformat(x) for x in args.dates.split(":")) elif args.time and "T" in args.time: a = b = datetime.fromisoformat(args.time).date() else: a, b = date(2025, 1, 1), date(2025, 12, 31) print("No date given: computing over the whole year. Posting time is not capture time; when phenology, clothing or snow can pin down the season, narrow it with --dates") groups: dict[str, list[float]] = {} lo_el, hi_el = (float(x) for x in args.elev.split(":")) if args.elev else (None, None) dropped: list[date] = [] d = a while d <= b: if args.time: hh, mm = (args.time.split("T")[-1].split(":") + ["0"])[:2] lt = datetime(d.year, d.month, d.day, int(hh), int(mm), tzinfo=tz) az, el = sun_position(lat, lon, lt.astimezone(timezone.utc)) if (el > -1 if lo_el is None else lo_el <= el <= hi_el): groups.setdefault(f"{int(hh):02d}:{int(mm):02d}", []).append(az) elif lo_el is not None: dropped.append(d) else: start = datetime(d.year, d.month, d.day, tzinfo=tz) for i in range(0, 24 * 60, 4): lt = start + timedelta(minutes=i) az, el = sun_position(lat, lon, lt.astimezone(timezone.utc)) if (0 < el <= args.elev_max) if lo_el is None else (lo_el <= el <= hi_el): groups.setdefault("morning (sunrise side)" if lt.hour < 12 else "evening (sunset side)", []).append(az) d += timedelta(days=max(1, args.step_days)) if dropped: runs, s0 = [], dropped[0] for x, y in zip(dropped, dropped[1:] + [None]): if y is None or (y - x).days > max(1, args.step_days): runs.append((s0, x)) s0 = y print("Dates excluded because the sun elevation is outside --elev: " + ", ".join(f"{p:%m-%d}–{q:%m-%d}" for p, q in runs)) if not groups: print("No matching sun position on these dates (sun below the horizon, or --elev / --elev-max too narrow)") return h0, h1 = (float(x) for x in args.hfov.split(":")) xs = [float(v) for s in (args.x or []) for v in s.split(",")] for name, azs in groups.items(): lo, hi = min(azs), max(azs) cands = [] for hf in (h0, h1): off = _px_angle(args.sun_x, args.width, hf) cands += [(lo - off) % 360, (hi - off) % 360] print(f"{name}: sun azimuth {lo:.1f}°–{hi:.1f}°; sun at frame x={args.sun_x:.0f} (offset from center " f"{_px_angle(args.sun_x, args.width, h0):+.1f}° to {_px_angle(args.sun_x, args.width, h1):+.1f}°)" f" → camera heading about {min(cands):.0f}°–{max(cands):.0f}°") for x in xs: bs = [] for hf in (h0, h1): rel = _px_angle(x, args.width, hf) - _px_angle(args.sun_x, args.width, hf) bs += [(lo + rel) % 360, (hi + rel) % 360] print(f" object at x={x:.0f}: bearing about {min(bs):.0f}°–{max(bs):.0f}° (relative to the sun " f"{_px_angle(x, args.width, h0) - _px_angle(args.sun_x, args.width, h0):+.1f}° to " f"{_px_angle(x, args.width, h1) - _px_angle(args.sun_x, args.width, h1):+.1f}°)") print("When a range crosses north (e.g. 350°–10°), read it clockwise. If the field of view is uncertain, widen --hfov; when the sunrise and sunset groups can't be told apart, you must build top-down templates for both headings.") def _neg_coords(argv: list[str]) -> list[str]: """argparse treats negative coordinates like -1.45,-48.5 as option names; a leading space makes them plain values (float ignores the space). Every southern- or western-hemisphere case needs this.""" return [" " + a if re.match(r"^-\d[\d.]*(,-?[\d.]+)+$", a) else a for a in argv] def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = ap.add_subparsers(dest="cmd", required=True) p = sub.add_parser("pos", help="sun position at a given place and time") p.add_argument("--at", type=_pair, required=True, help="lat,lon") p.add_argument("--time", required=True, help="2024-06-06T15:39, may include +08:00") p.add_argument("--tz", help="IANA time zone, e.g. Asia/Shanghai, Europe/Berlin") r = sub.add_parser("ratio", help="shadow ratio ↔ elevation angle") g = r.add_mutually_exclusive_group(required=True) g.add_argument("--shadow", type=float, help="shadow length for an object of height 1") g.add_argument("--elev", type=float) def targets(sp): sp.add_argument("--elev", type=float, help="sun elevation angle °") sp.add_argument("--ratio", type=float, help="shadow ratio (shadow length for an object of height 1); either this or --elev") sp.add_argument("--tol", type=float, default=2.0, help="elevation angle tolerance °, default 2") sp.add_argument("--shadow-bearing", type=float, help="compass bearing ° the shadow points to (from the object to the shadow tip)") sp.add_argument("--sun-azimuth", type=float, help="sun azimuth °; either this or --shadow-bearing") sp.add_argument("--az-tol", type=float, default=10.0, help="azimuth tolerance °, default 10") sp.add_argument("--tz") lo = sub.add_parser("locate", help="known time → find the band that matches the sun conditions") lo.add_argument("--time", required=True) targets(lo) lo.add_argument("--time-tol", type=float, default=0, help="uncertainty of the capture time in minutes (±)") lo.add_argument("--bbox", type=lambda s: tuple(map(float, s.split(","))), required=True, help="south,west,north,east") lo.add_argument("--step", type=float, default=0.1, help="grid step °, default 0.1 (about 11 km)") lo.add_argument("--points", type=Path, help="write matching points as {name:[lat,lon]}, for tiles.py mark") lo.add_argument("--mosaic", type=Path, help="base map from tiles.py fetch; the matching band is drawn on it") lo.add_argument("--out", type=Path) w = sub.add_parser("when", help="known place → work back to capture time / date") w.add_argument("--at", type=_pair, required=True) dg = w.add_mutually_exclusive_group(required=True) dg.add_argument("--date", help="single day 2024-10-01") dg.add_argument("--dates", help="date range 2024-01-01:2024-12-31") targets(w) w.add_argument("--step-min", type=int, default=2) d = sub.add_parser("dish", help="satellite dish pointing / work back to longitude from the pointing") d.add_argument("--sat", type=float, default=92.2, help="satellite longitude, default ChinaSat-9 92.2") d.add_argument("--at", type=_pair, help="lat,lon: forward calculation") d.add_argument("--lat", type=float, help="work back: known latitude") d.add_argument("--azimuth", type=float, help="work back: dish azimuth") d.add_argument("--az-tol", type=float, default=5.0) st = sub.add_parser("street", help="angle between shadow and street → street orientation candidates") st.add_argument("--at", type=_pair, required=True, help="any point in the city, lat,lon") st.add_argument("--date", required=True) st.add_argument("--shadow-rel", type=float, required=True, help="angle in a top-down view, clockwise from the street direction to the shadow direction; 90 if the shadow is perpendicular to the street") st.add_argument("--both", action="store_true", help="list both when you can't tell clockwise from counterclockwise") st.add_argument("--elev", type=float, help="measured sun elevation angle, used to keep only the matching times") st.add_argument("--ratio", type=float, help="shadow ratio; either this or --elev") st.add_argument("--tol", type=float, default=3.0) st.add_argument("--tz") st.add_argument("--step-min", type=int, default=10) fc = sub.add_parser("facing", help="which walls are lit → camera heading range") fc.add_argument("--at", type=_pair, required=True) fc.add_argument("--time", required=True) fc.add_argument("--tz") fc.add_argument("--lit", help="lit walls, comma-separated: camera,left,right,away") fc.add_argument("--shaded", help="shaded walls, same format") fc.add_argument("--margin", type=float, default=5, help="edge margin °, default 5") cp = sub.add_parser("compass", help="sun in the frame → camera heading, bearings of objects in the frame (lists sunrise and sunset groups when there is no time)") cp.add_argument("--at", type=_pair, required=True, help="any point in the candidate region, lat,lon") cp.add_argument("--time", help="clock time 07:40, or full 2024-09-20T07:40; if omitted, computes morning and evening groups for a low sun") cp.add_argument("--dates", help="date range 2024-09-01:2024-10-15; if omitted, uses the whole year") cp.add_argument("--step-days", type=int, default=3) cp.add_argument("--tz") cp.add_argument("--elev-max", type=float, default=12, help="when no time is given: upper limit of sun elevation ° (use 5–12 when the sun in the frame is close to the horizon)") cp.add_argument("--elev", help="sun elevation range ° lo:hi (estimate from how high the sun is above the horizon / tree line in the frame): with a time given, drops dates whose elevation doesn't fit; with no time, replaces 0–--elev-max") cp.add_argument("--sun-x", type=float, required=True, help="pixel column of the sun in the frame") cp.add_argument("--width", type=float, required=True, help="frame width (pixels)") cp.add_argument("--hfov", default="55:75", help="horizontal field of view range °; phone main camera about 65–75 in landscape, about 50–60 in portrait, narrower for crops / zoom") cp.add_argument("--x", action="append", help="pixel columns of objects to compute bearings for, comma-separated or repeated") args = ap.parse_args(_neg_coords(sys.argv[1:])) {"pos": cmd_pos, "ratio": cmd_ratio, "locate": cmd_locate, "when": cmd_when, "dish": cmd_dish, "street": cmd_street, "facing": cmd_facing, "compass": cmd_compass}[args.cmd](args) if __name__ == "__main__": # Chinese Windows outputs GBK by default: it crashes on m² or ñ, and the Chinese the agent reads is garbled sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace") main() -
terrain.py 93.6 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = ["pillow", "pillow-heif", "numpy"] # /// """Use elevation data to "look" out from a candidate camera position: synthesize a layered terrain view and skyline to compare ridgelines with the photo. When there is no street view, only distant mountains (group photos in the mountains, views across a river, aerial shots, a ridgeline outside a window), this replaces Google Earth oblique 3D. Elevation comes from AWS Terrain Tiles (Terrarium encoding, ~30 m worldwide, no key). view render from one camera position by heading and field of view: shading layered by distance + red skyline + bearing ticks; can be stacked above/below the photo profile output the skyline elevation angle and distance at each bearing (JSON), for numeric comparison elev look up the ground elevation at a point scan along linear infrastructure (railway, road, power line), compute the 360° horizon point by point, keep the points that are "flat nearby + a mountain + the mountain right next to a stretch of flat horizon" and cluster them — when the frame shows only one piece of infrastructure and a mountain you can't recognize, use it to cut a whole large region down to a few hundred patches ridge sample the photo's ridgeline: per column, find the sky-to-mountain brightness jump; output a pixel point list + flat-horizon column range + horizon row + focal length (ridge.json) fit score each scan cluster against ridge.json: lay a camera-position grid in each cluster, search heading × focal length × horizon offset, skyline RMS + flat-horizon penalty (+ optional: near layer of a two-layer photo, heading perpendicular to a line, how far the linear infrastructure is at the frame's left/center/right), sort by score, --sheet draws overlays of the top N Important: a matching skyline outline only shows that the camera position is near some sight line — moving a few hundred meters forward or back along the sight line barely changes the distant mountain outline. To pin a point you need a second independent constraint (another near–far object alignment, a road or riverbank on the map). See references/geometry.md. Examples: terrain.py elev --at 35.3606,138.7274 terrain.py view --at 35.4983,138.7688 --heading 194 --hfov 50 --range 30000 --out fuji.png # Mt. Fuji from Lake Kawaguchi terrain.py view --at <candidate camera position> --height 20 --heading <heading> --hfov 65 --out v.png --photo photo.jpg terrain.py profile --at 35.4983,138.7688 --heading 194 --hfov 50 --out prof.json terrain.py scan --lines rail_bridges.geojson --bbox 35.1,138.3,36.0,139.2 --out hits.json # get the lines with osm.py geom first terrain.py ridge photo.jpg --x0 760 --x1 1260 --step 20 --flat 0:280 --out ridge.json --png ridge.png terrain.py ridge photo.jpg --points "X,Y;X,Y;..." --near "X,Y;X,Y;..." --near-clear X0:X1@ROW --hrow ROW --out ridge.json # two-layer terrain.py fit --hits hits.json --ridge ridge.json --out fit.json --sheet top.jpg # coarse search: per cluster 2 km / 250 m / z11 terrain.py fit --at 35.4983,138.7688 --ridge ridge.json --radius 800 --grid 100 --zoom 13 --az-step 0.25 \\ --line rail.geojson --line-dist 350-750:550-900:800-1700 --out fine.json # fine search + infrastructure distance constraint terrain.py fit --hits hits.json --ridge ridge.json --range 45000 --min-peak 2 --perp-line lines.geojson --out fit.json # far range + wires overhead For the fields of ridge.json and fit.json, see each subcommand's --help. """ from __future__ import annotations import argparse from _image import frame_of, open_image, open_photo from _net import curl_args, PROXY_HELP import json import math import os import re import subprocess import sys import threading import time from concurrent.futures import ThreadPoolExecutor from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont sys.path.insert(0, str(Path(__file__).parent)) import geo # noqa: E402 TILE = "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png" R_EARTH = 6371008.8 K_REFRACTION = 0.13 F35_RANGE = (13.0, 135.0) # fit's focal ladder when the photo has no EXIF focal length: 0.5x ultra-wide to ~5x telephoto F35_RATIO = 1.18 # step between ladder rungs FETCH_TRIES = 4 MISSING_HELP = ("fill tiles that still fail after retries as missing (skipped in scan/fit, see-through in view/profile) instead of exiting; " "default: any failed tile is an error, because treating it as 0 m silently fakes sea-level terrain") def _decode(p: Path) -> np.ndarray | None: """Decode a cached Terrarium tile; None if it is not a valid 256×256 image (truncated download, HTTP error body).""" try: with Image.open(p) as im: if im.size != (256, 256): return None a = np.asarray(im.convert("RGB"), dtype=np.float32) except Exception: # noqa: BLE001 return None return a[..., 0] * 256 + a[..., 1] + a[..., 2] / 256 - 32768 def _fetch(z: int, x: int, y: int, cache: Path, proxy: str | None) -> np.ndarray | None: """Elevation of one tile, or None if it could not be downloaded after FETCH_TRIES attempts. Never caches a bad response.""" p = cache / f"terrarium_{z}_{x}_{y}.png" if p.exists(): a = _decode(p) if a is not None: return a p.unlink(missing_ok=True) # bad file left by an older version: download again tmp = p.with_name(f"{p.name}.{os.getpid()}.{threading.get_ident()}.part") cmd = ["curl", "-q", "-fsS", "-m", "60", "-o", str(tmp), TILE.format(z=z, x=x, y=y)] + curl_args(proxy) for k in range(FETCH_TRIES): if k: time.sleep(2 ** (k - 1)) # 1, 2, 4 s backoff r = subprocess.run(cmd, check=False, capture_output=True) a = _decode(tmp) if r.returncode == 0 else None if a is not None: os.replace(tmp, p) return a tmp.unlink(missing_ok=True) return None def _fetch_all(jobs: list[tuple[int, int]], zoom: int, cache: Path, proxy: str | None, threads: int, progress: bool = False): """Yield ((x, y), array or None) for every tile; download failures come back as None.""" with ThreadPoolExecutor(threads) as ex: for i, item in enumerate(zip(jobs, ex.map(lambda t: _fetch(zoom, t[0], t[1], cache, proxy), jobs)), 1): if progress and i % 200 == 0: print(f" tiles {i}/{len(jobs)}", file=sys.stderr) yield item def _report_missing(failed: list[tuple[int, int]], total: int, zoom: int, allow_missing: bool) -> None: """Fail loudly on missing tiles; with --allow-missing only warn.""" if not failed: return eg = ", ".join(f"{zoom}/{x}/{y}" for x, y in failed[:5]) + (" …" if len(failed) > 5 else "") msg = f"{len(failed)}/{total} elevation tiles failed to download after {FETCH_TRIES} tries ({eg})" if not allow_missing: sys.exit(f"{msg}. Good tiles are cached, so just re-run (lower --threads if it keeps failing, " "or check the network with doctor.py --network); --allow-missing proceeds without them") print(f"WARNING: {msg}; --allow-missing: continuing without them", file=sys.stderr) class DEM: """Elevation mosaic indexed by Web Mercator pixels.""" def __init__(self, center: tuple[float, float], radius_m: float, zoom: int, cache: Path, proxy: str | None, allow_missing: bool = False): cache.mkdir(parents=True, exist_ok=True) self.z = zoom mpp = geo.meters_per_px(zoom, center[0]) cx, cy = geo.ll2px(zoom, *center) r_px = radius_m / mpp + 256 self.tx0, self.ty0 = int((cx - r_px) // 256), int((cy - r_px) // 256) tx1, ty1 = int((cx + r_px) // 256), int((cy + r_px) // 256) nx, ny = tx1 - self.tx0 + 1, ty1 - self.ty0 + 1 if nx * ny > 400: sys.exit(f"Area too large ({nx}x{ny} tiles); reduce --range or lower --zoom") self.h = np.full((ny * 256, nx * 256), np.nan, dtype=np.float32) # missing tiles stay NaN, never 0 m jobs = [(x, y) for y in range(self.ty0, ty1 + 1) for x in range(self.tx0, tx1 + 1)] failed = [] for (x, y), arr in _fetch_all(jobs, zoom, cache, proxy, 8): if arr is None: failed.append((x, y)) continue self.h[(y - self.ty0) * 256:(y - self.ty0 + 1) * 256, (x - self.tx0) * 256:(x - self.tx0 + 1) * 256] = arr _report_missing(failed, len(jobs), zoom, allow_missing) def sample(self, lat: np.ndarray, lon: np.ndarray) -> np.ndarray: n = 256 * 2 ** self.z x = (lon + 180) / 360 * n - self.tx0 * 256 y = (1 - np.arcsinh(np.tan(np.radians(lat))) / np.pi) / 2 * n - self.ty0 * 256 x0, y0 = np.floor(x).astype(int), np.floor(y).astype(int) fx, fy = x - x0, y - y0 H, W = self.h.shape x0, y0 = np.clip(x0, 0, W - 2), np.clip(y0, 0, H - 2) a, b = self.h[y0, x0], self.h[y0, x0 + 1] c, d = self.h[y0 + 1, x0], self.h[y0 + 1, x0 + 1] return (a * (1 - fx) + b * fx) * (1 - fy) + (c * (1 - fx) + d * fx) * fy def _dest_np(lat0: float, lon0: float, brg: np.ndarray, dist: np.ndarray) -> tuple[np.ndarray, np.ndarray]: la1, lo1 = math.radians(lat0), math.radians(lon0) b = np.radians(brg)[:, None] d = (dist / R_EARTH)[None, :] la2 = np.arcsin(math.sin(la1) * np.cos(d) + math.cos(la1) * np.sin(d) * np.cos(b)) lo2 = lo1 + np.arctan2(np.sin(b) * np.sin(d) * math.cos(la1), np.cos(d) - math.sin(la1) * np.sin(la2)) return np.degrees(la2), np.degrees(lo2) def cast(dem: DEM, at, eye_alt: float, azimuths: np.ndarray, rng: float, near: float = 40.0, n: int = 900, keep_nan: bool = False): """Return (elevation angle ° of each sample in each column, sample distance m). n = samples per sight line (view/profile use 900; fit uses --nsamp). Samples on missing tiles (--allow-missing) become -90° so they never form a skyline; keep_nan=True leaves them NaN for the caller to detect.""" dist = near * (rng / near) ** (np.arange(n) / (n - 1)) # dense near, sparse far lat, lon = _dest_np(at[0], at[1], azimuths, dist) h = dem.sample(lat, lon) drop = dist ** 2 / (2 * R_EARTH) * (1 - K_REFRACTION) ang = np.degrees(np.arctan2(h - drop[None, :] - eye_alt, dist[None, :])) return (ang if keep_nan else np.nan_to_num(ang, nan=-90.0)), dist def render(ang: np.ndarray, dist: np.ndarray, azimuths: np.ndarray, pitch: float, vfov: float, height: int) -> tuple[Image.Image, np.ndarray, np.ndarray]: W = ang.shape[0] img = np.zeros((height, W, 3), dtype=np.uint8) img[:] = (205, 225, 245) # sky top = pitch + vfov / 2 def row(a): return (top - a) / vfov * (height - 1) sky_ang = np.full(W, -90.0) sky_dist = np.full(W, np.nan) runmax = np.maximum.accumulate(ang, axis=1) visible = ang >= np.concatenate([np.full((W, 1), -90.0), runmax[:, :-1]], axis=1) logd = np.log(dist / dist[0]) / np.log(dist[-1] / dist[0]) for c in range(W): idx = np.nonzero(visible[c])[0] prev = row(-90.0) # near to far: each visible sample paints this column from "its elevation angle" to "the previous highest point", color from dark to light by distance lo_row = height for i in idx: r = row(ang[c, i]) if r >= lo_row: continue shade = int(60 + 150 * logd[i]) r0 = max(0, int(r)) img[r0:lo_row, c] = (shade - 25, shade, shade - 35) lo_row = r0 if idx.size: j = idx[-1] sky_ang[c], sky_dist[c] = ang[c, j], dist[j] rr = int(round(row(ang[c, j]))) if 0 <= rr < height: img[max(0, rr - 1):rr + 2, c] = (220, 30, 30) _ = prev return Image.fromarray(img), sky_ang, sky_dist def _font(size: int): for p in ("/System/Library/Fonts/STHeiti Medium.ttc", "/System/Library/Fonts/Hiragino Sans GB.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"): try: return ImageFont.truetype(p, size) except Exception: # noqa: BLE001 continue return ImageFont.load_default() def annotate(im: Image.Image, azimuths: np.ndarray, pitch: float, vfov: float, title: str) -> Image.Image: W, H = im.size d = ImageDraw.Draw(im) f = _font(16) hr = (pitch + vfov / 2) / vfov * (H - 1) d.line([(0, hr), (W, hr)], fill=(90, 90, 200), width=1) # horizontal line at eye height names = {0: "N", 45: "NE", 90: "E", 135: "SE", 180: "S", 225: "SW", 270: "W", 315: "NW"} span = azimuths[-1] - azimuths[0] stepdeg = 5 if span <= 60 else 10 if span <= 150 else 30 first = math.ceil(azimuths[0] / stepdeg) * stepdeg for a in np.arange(first, azimuths[-1] + 1e-6, stepdeg): x = (a - azimuths[0]) / span * (W - 1) d.line([(x, H - 14), (x, H)], fill="black", width=1) lab = f"{a % 360:.0f}°" + (names.get(int(a % 360), "")) d.text((x + 3, H - 32), lab, fill="black", font=f) d.rectangle([0, 0, W, 24], fill=(0, 0, 0)) d.text((6, 3), title, fill="yellow", font=f) return im # ---------------------------------------------------------------- scan ---- # Linear infrastructure × terrain filtering. Method in references/corridors.md 4.3. # Difference from view/profile: this has to cover a province-sized region, far more tiles than the DEM class's 400-tile limit, # so it builds its own large mosaic filled only with "the tiles actually needed", sampled nearest-neighbor (one z10 cell is ~150 m; interpolation is pointless). def _tile_xy(lat: float, lon: float, z: int) -> tuple[float, float]: n = 2 ** z x = (lon + 180) / 360 * n y = (1 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2 * n return x, y def _haversine(a: tuple[float, float], b: tuple[float, float]) -> float: la1, lo1, la2, lo2 = map(math.radians, (a[0], a[1], b[0], b[1])) d = math.sin((la2 - la1) / 2) ** 2 + math.cos(la1) * math.cos(la2) * math.sin((lo2 - lo1) / 2) ** 2 return 2 * R_EARTH * math.asin(math.sqrt(d)) class _Mosaic: """A large mosaic stitched from a batch of Terrarium tiles, nearest-neighbor sampling. Tiles that failed to download hold MISSING.""" MISSING = -32768 # real elevations are clipped to -500…9000, so this never collides def __init__(self, need: set[tuple[int, int]], zoom: int, cache: Path, proxy: str | None, threads: int, allow_missing: bool = False): cache.mkdir(parents=True, exist_ok=True) self.z = zoom self.tx0 = min(t[0] for t in need) self.ty0 = min(t[1] for t in need) nx = max(t[0] for t in need) - self.tx0 + 1 ny = max(t[1] for t in need) - self.ty0 + 1 gb = nx * ny * 256 * 256 * 2 / 1e9 print(f"mosaic {nx}x{ny} tiles, {gb:.2f} GB memory", file=sys.stderr) self.h = np.full((ny * 256, nx * 256), self.MISSING, dtype=np.int16) jobs = sorted(need) failed = [] for (x, y), arr in _fetch_all(jobs, zoom, cache, proxy, threads, progress=True): if arr is None: failed.append((x, y)) continue self.h[(y - self.ty0) * 256:(y - self.ty0 + 1) * 256, (x - self.tx0) * 256:(x - self.tx0 + 1) * 256] = np.clip(arr, -500, 9000).astype(np.int16) self.n_failed = len(failed) _report_missing(failed, len(jobs), zoom, allow_missing) def sample(self, lat: np.ndarray, lon: np.ndarray) -> np.ndarray: n = 256 * 2 ** self.z x = ((lon + 180) / 360 * n - self.tx0 * 256).astype(int) y = ((1 - np.arcsinh(np.tan(np.radians(lat))) / np.pi) / 2 * n - self.ty0 * 256).astype(int) x = np.clip(x, 0, self.h.shape[1] - 1) y = np.clip(y, 0, self.h.shape[0] - 1) return self.h[y, x].astype(np.float32) def _line_points(gj: dict, step: float, skip: list[tuple[str, str]], bbox: tuple[float, float, float, float] | None): """Take one point every step meters along each LineString; return [(lat, lon, properties)].""" pts = [] n_line = n_skip = 0 for f in gj.get("features", []): g = f.get("geometry") or {} if g.get("type") != "LineString" or len(g.get("coordinates", [])) < 2: continue tags = f.get("properties", {}) or {} n_line += 1 if any(str(tags.get(k, "")) == v for k, v in skip): n_skip += 1 continue c = [(y, x) for x, y in g["coordinates"]] # geojson is lon,lat if bbox and not (min(p[0] for p in c) <= bbox[2] and max(p[0] for p in c) >= bbox[0] and min(p[1] for p in c) <= bbox[3] and max(p[1] for p in c) >= bbox[1]): continue got = 0 acc = 0.0 for i in range(1, len(c)): d = _haversine(c[i - 1], c[i]) for k in range(int((acc + d) // step)): t = (step * (k + 1) - acc) / d if d else 0 pts.append((c[i - 1][0] + (c[i][0] - c[i - 1][0]) * t, c[i - 1][1] + (c[i][1] - c[i - 1][1]) * t, tags)) got += 1 acc = (acc + d) % step # lines shorter than step overall, or whose ends nearly coincide (loops), get one midpoint added so the whole line isn't missed if got == 0 or _haversine(c[0], c[-1]) < step: pts.append((c[len(c) // 2][0], c[len(c) // 2][1], tags)) if bbox: s, w, n, e = bbox pts = [p for p in pts if s <= p[0] <= n and w <= p[1] <= e] print(f"{n_line} lines ({n_skip} skipped by --skip-tag), {len(pts)} sample points", file=sys.stderr) return pts def _cluster(hits: list[dict], km: float) -> list[dict]: """Greedy clustering by max_ang from largest to smallest: the steepest point represents the cluster, points within km join it, and the representative gets n = number of points in the cluster.""" order = sorted(hits, key=lambda h: -h["max_ang"]) used = [False] * len(order) out = [] for i, p in enumerate(order): if used[i]: continue n = 0 for j in range(i, len(order)): if not used[j] and _haversine((p["lat"], p["lon"]), (order[j]["lat"], order[j]["lon"])) <= km * 1000: used[j] = True n += 1 out.append({**p, "n": n}) return out def _cmd_scan(args) -> None: az_step = args.az_step naz = int(round(360 / az_step)) if abs(naz * az_step - 360) > 1e-6: sys.exit("--az-step must divide 360 evenly") if args.step <= 0 or args.near_step <= 0 or args.near_radius < 0: sys.exit("--step / --near-step must be greater than 0, --near-radius must not be negative") dist = np.array([float(x) for x in args.dist.split(",")], dtype=float) near = np.arange(0, args.near_radius + 1e-6, args.near_step, dtype=float) skip = [] for s in (args.skip_tag or ["electrified=no"]): if s.lower() in ("none", "-"): continue if "=" not in s: sys.exit(f"--skip-tag must be written as key=value, got {s!r}") k, v = s.split("=", 1) skip.append((k, v)) bbox = None if args.bbox: b = [float(x) for x in args.bbox.split(",")] if len(b) != 4: sys.exit("--bbox needs 4 numbers: s,w,n,e") bbox = (min(b[0], b[2]), min(b[1], b[3]), max(b[0], b[2]), max(b[1], b[3])) gj = json.loads(Path(args.lines).read_text(encoding="utf-8")) pts = _line_points(gj, args.step, skip, bbox) if not pts: sys.exit("0 sample points: --bbox encloses nothing, or the geojson has no LineString") reach = float(dist.max()) + 500 # half a kilometer extra, enough even when a sample point falls on a tile edge need: set[tuple[int, int]] = set() for lat, lon, _ in pts: x, y = _tile_xy(lat, lon, args.zoom) r = reach / (40075016.7 * math.cos(math.radians(lat)) / 2 ** args.zoom) for tx in range(int(x - r), int(x + r) + 1): for ty in range(int(y - r), int(y + r) + 1): need.add((tx, ty)) print(f"need {len(need)} z{args.zoom} tiles", file=sys.stderr) if len(need) > args.max_tiles: sys.exit(f"{len(need)} tiles > --max-tiles {args.max_tiles}: shrink --bbox, lower --zoom, or run in chunks") mos = _Mosaic(need, args.zoom, args.cache, args.proxy, args.threads, args.allow_missing) az = np.arange(0, 360, az_step) min_low = int(round((args.min_low_deg if args.min_low_deg is not None else args.flat_run) / az_step)) need_run = int(round(args.flat_run / az_step)) cap = int(round(args.flat_run_cap / az_step)) hits = [] n_missing = 0 for idx, (lat, lon, tags) in enumerate(pts): if idx and idx % 2000 == 0: print(f" scanned {idx}/{len(pts)}, hits {len(hits)}", file=sys.stderr) ln, lo = _dest_np(lat, lon, az, near) hn = mos.sample(ln, lo) if (hn == mos.MISSING).any(): n_missing += 1 continue if hn.max() - hn.min() > args.near_flat: # not flat nearby: a valley or hillside, not the flat ground seen in the frame continue h0 = float(np.median(hn)) la, lo = _dest_np(lat, lon, az, dist) h = mos.sample(la, lo) if (h == mos.MISSING).any(): # a hole would read as a deep valley or flat horizon n_missing += 1 continue ang = np.degrees(np.arctan2(h - h0 - args.eye, dist[None, :])) # within a few km earth curvature is <5 m, negligible next to z10 error hor = ang.max(axis=1) # horizon elevation angle at each bearing rel = (h - h0).max(axis=1) mt = hor >= args.min_peak low = hor < args.max_low if not mt.any() or low.sum() < min_low: continue best = 0 # how long the flat horizon right next to the mountain is for i in range(naz): if not mt[i]: continue for sgn in (-1, 1): k = run = 0 while k < cap: jj = (i + sgn * (k + 1)) % naz if low[jj]: run += 1 elif run == 0 and k < 2: # allow a 2-cell transition between the mountain foot and flat ground pass else: break k += 1 best = max(best, run) if best < need_run: continue i = int(np.argmax(hor)) hits.append({"lat": round(lat, 5), "lon": round(lon, 5), "h0": round(h0), "max_ang": round(float(hor[i]), 1), "az": float(az[i]), "relief": round(float(rel[i])), "flat_run_deg": best * az_step, "name": tags.get("name", ""), "hs": tags.get("highspeed", ""), "elec": tags.get("electrified", ""), "id": tags.get("id", tags.get("@id", ""))}) clusters = _cluster(hits, args.cluster_km) print(f"{len(hits)} hit points → {len(clusters)} clusters", file=sys.stderr) if n_missing: print(f"WARNING: {n_missing}/{len(pts)} sample points not scanned because they touch missing tiles", file=sys.stderr) out = { "params": {"lines": str(args.lines), "bbox": list(bbox) if bbox else None, "step_m": args.step, "zoom": args.zoom, "near_flat_m": args.near_flat, "near_radius_m": args.near_radius, "min_peak_deg": args.min_peak, "max_low_deg": args.max_low, "flat_run_deg": args.flat_run, "min_low_deg": args.min_low_deg if args.min_low_deg is not None else args.flat_run, "eye_m": args.eye, "az_step_deg": az_step, "dist_m": dist.tolist(), "skip_tag": [f"{k}={v}" for k, v in skip], "cluster_km": args.cluster_km}, "n_samples": len(pts), "n_hits": len(hits), "n_clusters": len(clusters), "n_failed_tiles": mos.n_failed, "n_samples_missing_dem": n_missing, "hits": hits, "clusters": clusters, } Path(args.out).write_text(json.dumps(out, ensure_ascii=False, indent=0), encoding="utf-8") print(f"→ {args.out}", file=sys.stderr) if args.clusters_out: Path(args.clusters_out).write_text(json.dumps(clusters, ensure_ascii=False, indent=0), encoding="utf-8") print(f"→ {args.clusters_out} (cluster list only)", file=sys.stderr) # ---------------------------------------------------------------- ridge ---- # Photo ridgeline sampling. Method in references/geometry.md 7.4: per column, top to bottom, find the sky→mountain brightness jump. def _focal_px(photo: Path, w: int, h: int, f0_arg: float | None, f35_default: float): """Photo focal length (pixels). Priority: --f0 > EXIF 35mm-equivalent focal length > assumed from --f35. The 35mm equivalent converts via the diagonal: f_px = f35 / 43.27 × diagonal pixels; this holds even if the photo was resized. Return (f_px, source, f35).""" diag = math.hypot(w, h) if f0_arg: return float(f0_arg), "arg", round(f0_arg * 43.2666 / diag, 1) try: ifd = open_image(photo, load=False).getexif().get_ifd(0x8769) f35 = ifd.get(0xA405) # FocalLengthIn35mmFilm if f35 and float(f35) > 0: return float(f35) / 43.2666 * diag, "exif", float(f35) except Exception: # noqa: BLE001 pass return f35_default / 43.2666 * diag, "assumed", f35_default def _parse_pts(spec: str, name: str, W: int, H: int) -> list[list[int]]: """Pixel points as "x,y;x,y;..." or a JSON file holding [[x,y],...].""" p = Path(spec) if p.suffix == ".json" and p.exists(): raw = json.loads(p.read_text(encoding="utf-8")) pts = raw.get("ridge", raw) if isinstance(raw, dict) else raw else: try: pts = [[float(v) for v in part.split(",")] for part in spec.split(";") if part.strip()] except ValueError: sys.exit(f"{name} must be x,y;x,y;... or a .json file of [[x,y],...], got {spec!r}") out = [] for q in pts: if len(q) != 2 or not (0 <= q[0] < W and 0 <= q[1] < H): sys.exit(f"{name}: point {q} is not x,y inside the {W}x{H} photo") out.append([int(round(q[0])), int(round(q[1]))]) if len(out) < 3: sys.exit(f"{name} needs at least 3 points, got {len(out)}") return sorted(out) def _sky_edge(prof: np.ndarray, ymin: int, ymax: int, k: int, drop: float, hold: int) -> int | None: """In one column's brightness profile, top to bottom, find the first row where "the mean of the k rows above exceeds the mean of the k rows below by drop, and the hold rows further down are still dark", then take the maximum-gradient row within ±k rows of it. Return the row index of the mountain's first row; None if not found. hold is there to skip things only a few pixels tall vertically, such as wires and antennas.""" n = len(prof) cs = np.concatenate([[0.0], np.cumsum(prof, dtype=np.float64)]) y = np.arange(max(ymin, k), min(ymax, n - k - hold)) if y.size == 0: return None above = (cs[y] - cs[y - k]) / k below = (cs[y + k] - cs[y]) / k held = (cs[y + k + hold] - cs[y + k]) / hold ok = (above - below >= drop) & (above - held >= drop) if not ok.any(): return None y0 = int(y[np.argmax(ok)]) yy = np.arange(max(k, y0 - k), min(n - k, y0 + k + 1)) g = (cs[yy] - cs[yy - k]) / k - (cs[yy + k] - cs[yy]) / k return int(yy[np.argmax(g)]) def _cmd_ridge(args) -> None: im = open_photo(args.photo, "photo").convert("RGB") W, H = im.size a = np.asarray(im, dtype=np.float32) L = a[..., 0] * 0.299 + a[..., 1] * 0.587 + a[..., 2] * 0.114 if args.x1 is None: args.x1 = W - 1 if not (0 <= args.x0 < args.x1 < W): sys.exit(f"--x0/--x1 must satisfy 0 ≤ x0 < x1 < width {W}") if args.step <= 0: sys.exit("--step must be greater than 0") ymin = args.ymin if args.ymin is not None else 0 ymax = args.ymax if args.ymax is not None else H f0, f_src, f35 = _focal_px(args.photo, W, H, args.f0, args.f35) def edge(x: int) -> int | None: x0, x1 = max(0, x - args.halfw), min(W, x + args.halfw + 1) return _sky_edge(L[:, x0:x1].mean(axis=1), ymin, ymax, args.k, args.drop, args.hold) ridge, missing = [], [] if args.points: # hand-picked skyline replaces detection (cloud on the peaks, haze, snow against sky) ridge = _parse_pts(args.points, "--points", W, H) else: for x in range(args.x0, args.x1 + 1, args.step): y = edge(x) (ridge.append([x, y]) if y is not None else missing.append(x)) near = _parse_pts(args.near, "--near", W, H) if args.near else [] near_clear = [] for spec in args.near_clear or []: m = re.match(r"^\s*(\d+):(\d+)@(\d+(?:\.\d+)?)\s*$", spec) if not m or not 0 <= int(m[1]) < int(m[2]) < W: sys.exit(f"--near-clear must be x0:x1@row with 0 ≤ x0 < x1 < width {W}, got {spec!r}") near_clear.append([int(m[1]), int(m[2]), float(m[3])]) flat = None flat_rows: list[int] = [] flat_pts: list[list[int]] = [] # for drawing the check image: flat_rows has only row indices and omits columns with no jump found, so index order can't map back to columns if args.flat: b = args.flat.split(":") if len(b) != 2: sys.exit("--flat must be written as x0:x1") flat = [int(b[0]), int(b[1])] if not (0 <= flat[0] < flat[1] < W): sys.exit(f"--flat must satisfy 0 ≤ x0 < x1 < width {W}") for x in range(flat[0], flat[1] + 1, args.step): y = edge(x) if y is not None: flat_rows.append(y) flat_pts.append([x, y]) if args.hrow is not None: hrow, h_src = float(args.hrow), "arg" elif flat_rows: hrow, h_src = float(np.median(flat_rows)), "flat" else: hrow, h_src = H / 2, "center" if len(ridge) < 3: print(f"warning: only {len(ridge)} ridge points found ({len(missing)} columns missing), fit needs at least 3; adjust --drop/--ymin/--ymax or check --png", file=sys.stderr) out = { "photo": str(args.photo), "image_size": [W, H], "frame": "upright", "cx": W / 2, "ridge": ridge, "missing_cols": missing, "ridge_source": "points" if args.points else "detected", "near": near, "near_clear": near_clear, "flat": flat, "flat_rows": flat_rows, "hrow": round(hrow, 1), "hrow_source": h_src, "f0": round(f0, 1), "f0_source": f_src, "f35_equiv": f35, "params": {"x0": args.x0, "x1": args.x1, "step": args.step, "ymin": ymin, "ymax": ymax, "drop": args.drop, "k": args.k, "hold": args.hold, "halfw": args.halfw}, } Path(args.out).write_text(json.dumps(out, ensure_ascii=False), encoding="utf-8") src_txt = {"arg": "--f0", "exif": "EXIF 35mm equivalent", "assumed": f"no EXIF, assumed {f35} mm equivalent; fit tries {F35_RANGE[0]:g}–{F35_RANGE[1]:g} mm by default"}[f_src] h_txt = {"arg": "--hrow", "flat": f"median sky→ground row in the --flat columns ({len(flat_rows)} columns)", "center": "no --flat given, frame midline"}[h_src] print(f"{W}x{H} ridge {len(ridge)} points ({len(missing)} columns missing) flat-horizon columns {flat} " f"hrow {hrow:.0f} ({h_txt}) f0 {f0:.0f} px ({src_txt}, horizontal FOV {2 * math.degrees(math.atan(W / 2 / f0)):.1f}°)" + (f" near layer {len(near)} points, {len(near_clear)} clear ranges" if near or near_clear else "") + f" → {args.out}") if args.png: d = ImageDraw.Draw(im) d.line([(0, hrow), (W, hrow)], fill=(80, 80, 255), width=1) if flat: d.line([(flat[0], hrow), (flat[1], hrow)], fill=(0, 200, 255), width=4) for x, y in flat_pts: d.ellipse([x - 3, y - 3, x + 3, y + 3], outline=(0, 200, 255), width=2) for x, y in ridge: d.ellipse([x - 4, y - 4, x + 4, y + 4], fill=(255, 220, 0)) for x in missing: d.line([(x, ymin), (x, ymax - 1)], fill=(255, 0, 0), width=1) for x, y in near: d.ellipse([x - 4, y - 4, x + 4, y + 4], fill=(255, 120, 0)) for x0, x1, row in near_clear: d.line([(x0, row), (x1, row)], fill=(255, 120, 0), width=3) d.text((8, 8), f"ridge {len(ridge)} pts hrow {hrow:.0f} ({h_src}) f0 {f0:.0f}px ({f_src})", fill="yellow", font=_font(20)) im.save(args.png, quality=90) print(f"check image → {args.png} (yellow dots = ridge points, red vertical lines = columns not found, blue line = hrow, cyan line = flat-horizon column range, " "orange dots = near-layer points, orange lines = near-clear ranges)") # ------------------------------------------------------------------ fit ---- # Batch skyline scoring. Method in references/geometry.md 7.4; infrastructure distance constraint in corridors.md 4.3. # For each candidate camera position compute a full 360° horizon hor(az); convert photo ridge points to (bearing offset, elevation angle) and compare against all headings at once: # rms = RMS of the ridge elevation-angle residuals (the residual median serves as horizon offset cc, clamped to ±cc_max) # flatpen = mean of max(0, hor - cc - flat_clear) over the flat-horizon columns # score = rms + flat_w × flatpen # With --line, also add: for each heading, the nearest distance dL/dC/dR of infrastructure sample points inside the frame's left/center/right bearing windows, # line_pen = meters each falls outside its --line-dist interval / --line-scale (+ penalty 1 for wrong order) # total = score + line_w × line_pen; without --line, total = score. # Two-layer photos (ridge.json has near / near_clear): near(az) = highest angle within D1 m, D1 tried from --near-dist; near points join # the far ridge points in the same rms (one cc and roll for both), near_clear columns add nclearpen = mean of max(0, near − row angle). # --perp-line keeps only headings within --perp-tol of perpendicular to the nearest line (wires crossing the frame almost level). def _parse_pairs(s: str, name: str, n: int = 3, sep: str = ",", rng: str = ":") -> list[tuple[float, float]]: out = [] for part in s.split(sep): b = part.split(rng) if len(b) != 2: sys.exit(f"{name}: each segment must be written as a{rng}b, got {part!r}") out.append((float(b[0]), float(b[1]))) if len(out) != n: sys.exit(f"{name} needs {n} segments, got {len(out)}") return out def _load_centers(args) -> list[dict]: if args.at: lat, lon = map(float, args.at.split(",")) return [{"lat": lat, "lon": lon, "name": "", "src_idx": 0}] j = json.loads(Path(args.hits).read_text(encoding="utf-8")) items = j["clusters"] if isinstance(j, dict) and "clusters" in j else j if not isinstance(items, list): sys.exit("--hits must be scan output (with a clusters field) or a list of clusters/points") centers = [] for i, c in enumerate(items): if "lat" not in c or "lon" not in c: sys.exit(f"--hits item {i} has no lat/lon") centers.append({**c, "src_idx": c.get("src_idx", i)}) if args.select: want = [int(x) for x in args.select.split(",")] bad = [i for i in want if not 0 <= i < len(centers)] if bad: sys.exit(f"--select indices {bad} out of range 0–{len(centers) - 1}") centers = [centers[i] for i in want] return centers def _skyline_xy(hor: np.ndarray, az: np.ndarray, H: float, f: float, cc: float, cx: float, hrow: float, half_w: float, roll: float = 0.0): """Project the 360° horizon onto photo pixels with the pinhole model fit uses: x = cx + f·tan(bearing offset), y = hrow − f·tan(elevation angle − cc). roll = frame roll angle ° (clockwise positive); pass the value fit solved for, otherwise both ends will be off from the photo by a dozen-plus pixels.""" rel = (az - H + 180) % 360 - 180 lim = math.degrees(math.atan(half_w / f)) + 1 m = np.abs(rel) <= lim x = cx + f * np.tan(np.radians(rel[m])) y = hrow - f * np.tan(np.radians(hor[m] - cc)) + math.tan(math.radians(roll)) * (x - cx) o = np.argsort(x) return list(zip(x[o].tolist(), y[o].tolist())) def _fit_sheet(recs: list[dict], ridge: dict, photo: Path | None, out: Path, args, az: np.ndarray, dist_n: int) -> None: W, H = ridge["image_size"] tw = args.sheet_width s = tw / W th = int(H * s) base = None if photo and photo.exists(): base = open_photo(photo, "photo", frame=frame_of(ridge), size=[W, H]).convert("RGB").resize((tw, th)) cols = max(1, args.sheet_cols) rows = math.ceil(len(recs) / cols) sheet = Image.new("RGB", (tw * cols, th * rows), "white") font = _font(13) for i, r in enumerate(recs): tile = base.copy() if base else Image.new("RGB", (tw, th), (120, 120, 120)) d = ImageDraw.Draw(tile) hrow, f, cx = ridge["hrow"], r["f"], ridge.get("cx", W / 2) d.line([(0, hrow * s), (tw, hrow * s)], fill=(80, 80, 255), width=1) if ridge.get("flat"): d.line([(ridge["flat"][0] * s, hrow * s), (ridge["flat"][1] * s, hrow * s)], fill=(0, 200, 255), width=3) try: dem = DEM(tuple(r["cam"]), args.range + 500, args.zoom, args.cache, args.proxy, args.allow_missing) ang, dist_v = cast(dem, tuple(r["cam"]), r["g"] + args.eye, az, args.range, near=args.near, n=dist_n) pts = _skyline_xy(ang.max(axis=1), az, r["H"], f, r["cc"], cx, hrow, W / 2, r.get("roll", 0.0)) d.line([(x * s, y * s) for x, y in pts], fill=(255, 40, 40), width=2) if r.get("D1"): pts = _skyline_xy(ang[:, dist_v <= r["D1"]].max(axis=1), az, r["H"], f, r["cc"], cx, hrow, W / 2, r.get("roll", 0.0)) d.line([(x * s, y * s) for x, y in pts], fill=(255, 140, 0), width=2) except SystemExit as e: d.text((6, th - 20), f"DEM failed: {e}", fill="red", font=font) for x, y in ridge["ridge"]: d.ellipse([x * s - 2, y * s - 2, x * s + 2, y * s + 2], fill=(255, 220, 0)) for x, y in ridge.get("near") or []: d.ellipse([x * s - 2, y * s - 2, x * s + 2, y * s + 2], fill=(0, 220, 255)) for x0, x1, row in ridge.get("near_clear") or []: d.line([(x0 * s, row * s), (x1 * s, row * s)], fill=(0, 220, 255), width=2) line_txt = f" L/C/R {r['dL']:.0f}/{r['dC']:.0f}/{r['dR']:.0f} pen {r['line_pen']:.2f}" if "line_pen" in r else "" txt = (f"#{i + 1} hit{r['hit']} {r['name'] or '-'} {r['cam'][0]:.5f},{r['cam'][1]:.5f}\n" f"H {r['H']:.1f} f {f:.0f} ({r.get('f35', 0):.0f}mm) cc {r['cc']:+.2f} rms {r['rms']:.3f} flat {r['flatpen']:.3f}{line_txt}\n" f"total {r['total']:.3f}") d.rectangle([0, 0, tw, 46], fill=(0, 0, 0)) d.text((4, 2), txt, fill="yellow", font=font) sheet.paste(tile, ((i % cols) * tw, (i // cols) * th)) sheet.save(out, quality=88) print(f"overlay → {out} (red line = synthetic skyline, orange line = synthetic near layer, yellow dots = photo ridge points, " "cyan dots/lines = photo near points / near-clear rows, blue line = hrow, cyan line on hrow = flat-horizon columns)", file=sys.stderr) def _cmd_fit(args) -> None: if bool(args.hits) == bool(args.at): sys.exit("give exactly one of --hits and --at") ridge = json.loads(Path(args.ridge).read_text(encoding="utf-8")) RX = np.array([p[0] for p in ridge["ridge"]], float) RY = np.array([p[1] for p in ridge["ridge"]], float) if RX.size < 3: sys.exit(f"ridge.json has only {RX.size} ridge points, needs at least 3") W, Hh = ridge["image_size"] hrow, f0, cx = float(ridge["hrow"]), float(ridge["f0"]), float(ridge.get("cx", W / 2)) flat = ridge.get("flat") FLX = np.arange(flat[0], flat[1] + 1e-6, args.flat_step, dtype=float) if flat else np.zeros(0) NX = np.array([p[0] for p in ridge.get("near") or []], float) NY = np.array([p[1] for p in ridge.get("near") or []], float) ncl = ridge.get("near_clear") or [] NCX = np.concatenate([np.arange(a, b + 1e-6, args.flat_step) for a, b, _ in ncl]) if ncl else np.zeros(0) NCR = np.concatenate([np.full(len(np.arange(a, b + 1e-6, args.flat_step)), r) for a, b, r in ncl]) if ncl else np.zeros(0) two = bool(NX.size or NCX.size) near_dists = [float(x) for x in args.near_dist.split(",")] if two else [None] if two: dropped = [d_ for d_ in near_dists if d_ <= args.near] near_dists = [d_ for d_ in near_dists if d_ > args.near] if not near_dists: sys.exit(f"every --near-dist value ({args.near_dist}) is within --near {args.near:g} m, where sight lines start: " "give near distances beyond --near") if dropped: print(f"note: --near-dist {', '.join(f'{d_:g}' for d_ in dropped)} m dropped, within --near {args.near:g} m", file=sys.stderr) diag = math.hypot(W, Hh) f35_0 = f0 * 43.2666 / diag f_src = ridge.get("f0_source", "assumed") if args.focal_scales != "auto" and args.f35_range: sys.exit("give --focal-scales or --f35-range, not both") if args.focal_scales != "auto": fscales = [float(x) for x in args.focal_scales.split(",")] focal_how = "--focal-scales" elif args.f35_range or f_src == "assumed": try: lo, hi = (float(x) for x in args.f35_range.split(":")) if args.f35_range else F35_RANGE except ValueError: sys.exit(f"--f35-range must be lo:hi in mm, got {args.f35_range!r}") if not 0 < lo < hi: sys.exit("--f35-range needs 0 < lo < hi") n = max(2, math.ceil(math.log(hi / lo) / math.log(F35_RATIO)) + 1) fscales = [round(lo * (hi / lo) ** (i / (n - 1)) / f35_0, 4) for i in range(n)] focal_how = f"{lo:g}–{hi:g} mm equivalent, {n} steps" else: fscales = [0.9, 1.0, 1.12] focal_how = f"focal from {'EXIF' if f_src == 'exif' else '--f0'} × 0.9–1.12" px_metric = args.focal_metric == "px" or (args.focal_metric == "auto" and focal_how.endswith("steps")) print(f"focal ladder: {focal_how}, compared in {'pixels' if px_metric else 'degrees'} → " + ", ".join(f"{f35_0 * x:.0f}" for x in fscales) + " mm" + (f"; two-layer: {NX.size} near points, {NCX.size} near-clear columns, near distance {args.near_dist} m" if two else ""), file=sys.stderr) if args.grid <= 0 or args.radius < 0 or args.nsamp < 2 or args.near <= 0 or args.range <= args.near: sys.exit("need --grid > 0, --radius ≥ 0, --nsamp ≥ 2, 0 < --near < --range") az_step = args.az_step naz = int(round(360 / az_step)) if abs(naz * az_step - 360) > 1e-6: sys.exit("--az-step must divide 360 evenly") if not 0 <= args.roll_max < 15: sys.exit("--roll-max must be between 0–15° (0 = don't solve roll, same as the old version)") roll_max = math.tan(math.radians(args.roll_max)) AZ = np.arange(naz) * az_step HS = np.arange(naz) centers = _load_centers(args) if not centers: sys.exit("no candidate clusters") PS = None # (n, 4) segments lat1, lon1, lat2, lon2 for the heading constraint if args.perp_line: segs = [] for f_ in json.loads(Path(args.perp_line).read_text(encoding="utf-8")).get("features", []): g_ = f_.get("geometry") or {} parts = [g_["coordinates"]] if g_.get("type") == "LineString" else g_.get("coordinates", []) if g_.get("type") == "MultiLineString" else [] for c_ in parts: segs += [(y1, x1, y2, x2) for (x1, y1, *_), (x2, y2, *_) in zip(c_, c_[1:])] if not segs: sys.exit(f"--perp-line {args.perp_line}: no LineString in it") PS = np.array(segs, float) # linear infrastructure sample points (optional) LP = None if args.line: if not args.line_dist: sys.exit("--line requires --line-dist L:C:R (three distance intervals, e.g. 350-750:550-900:800-1700)") wins = _parse_pairs(args.line_win, "--line-win", 3, ",", ":") dwin = _parse_pairs(args.line_dist, "--line-dist", 3, ":", "-") scales = [float(x) for x in args.line_scale.split(",")] if len(scales) != 3 or min(scales) <= 0: sys.exit("--line-scale needs 3 positive numbers, comma-separated") skip = [] for s_ in (args.skip_tag or ["electrified=no"]): if s_.lower() in ("none", "-"): continue if "=" not in s_: sys.exit(f"--skip-tag must be written as key=value, got {s_!r}") skip.append(tuple(s_.split("=", 1))) reach_deg = (args.radius + args.line_reach) / 110540 + 0.01 lat_c = [c["lat"] for c in centers] lon_c = [c["lon"] for c in centers] bbox = (min(lat_c) - reach_deg, min(lon_c) - reach_deg * 1.2, max(lat_c) + reach_deg, max(lon_c) + reach_deg * 1.2) gj = json.loads(Path(args.line).read_text(encoding="utf-8")) pts = _line_points(gj, args.line_sample, skip, bbox) LP = np.array([[p[0], p[1]] for p in pts], dtype=float) if pts else np.zeros((0, 2)) print(f"{len(LP)} infrastructure sample points (every {args.line_sample} m)", file=sys.stderr) ring_az = np.arange(0, 360, 45.0) ring_d = np.array([0.0, args.cam_flat_radius / 2, args.cam_flat_radius]) done: set[tuple[int, int]] = set() recs: list[dict] = [] clusters_out: list[dict] = [] skipped = {"not_flat": 0, "no_peak": 0, "no_line": 0, "dup": 0, "missing_dem": 0, "no_perp_line": 0} dem_failed = [] grid_pts = [(dx, dy) for dy in np.arange(-args.radius, args.radius + 1e-6, args.grid) for dx in np.arange(-args.radius, args.radius + 1e-6, args.grid) if dx * dx + dy * dy <= args.radius ** 2] if args.radius == 0: grid_pts = [(0.0, 0.0)] for ci, c in enumerate(centers): lat, lon = float(c["lat"]), float(c["lon"]) name = c.get("name", "") try: dem = DEM((lat, lon), args.radius + args.range + 500, args.zoom, args.cache, args.proxy, args.allow_missing) except SystemExit as e: print(f"cluster {c['src_idx']} {name}: DEM failed ({e}), skipped", file=sys.stderr) dem_failed.append(c["src_idx"]) continue kx = 111320 * math.cos(math.radians(lat)) sub = None if LP is not None and len(LP): m = (np.abs(LP[:, 0] - lat) * 110540 < args.radius + args.line_reach) & (np.abs(LP[:, 1] - lon) * kx < args.radius + args.line_reach) sub = LP[m] n_c = 0 best_c = None for dx, dy in grid_pts: dx, dy = float(dx), float(dy) clat = lat + dy / 110540 clon = lon + dx / kx key = (round(clat * 110540 / args.grid), round(clon * kx / args.grid)) if key in done: skipped["dup"] += 1 continue done.add(key) rla, rlo = _dest_np(clat, clon, ring_az, ring_d) g = dem.sample(rla, rlo) if np.isnan(g).any(): skipped["missing_dem"] += 1 continue if g.max() - g.min() > args.cam_flat: skipped["not_flat"] += 1 continue g0 = float(g[:, 0].mean()) ang, dist_v = cast(dem, (clat, clon), g0 + args.eye, AZ, args.range, near=args.near, n=args.nsamp, keep_nan=True) if np.isnan(ang).any(): # a missing far tile would drop a ridge out of the skyline; don't rank on it skipped["missing_dem"] += 1 continue hor = ang.max(axis=1) if hor.max() < args.min_peak: skipped["no_peak"] += 1 continue h_ok = None line_brg = None if PS is not None: kxc = math.cos(math.radians(clat)) # distance to each segment, not its midpoint: a long span's midpoint can be km away while the wire passes overhead ay, ax = (PS[:, 0] - clat) * 110540, (PS[:, 1] - clon) * 111320 * kxc by, bx = (PS[:, 2] - clat) * 110540, (PS[:, 3] - clon) * 111320 * kxc L2 = (bx - ax) ** 2 + (by - ay) ** 2 t = np.clip(-(ax * (bx - ax) + ay * (by - ay)) / np.where(L2 > 0, L2, 1), 0, 1) d2 = (ax + t * (bx - ax)) ** 2 + (ay + t * (by - ay)) ** 2 i_ = int(np.argmin(d2)) if d2[i_] > args.perp_reach ** 2: skipped["no_perp_line"] += 1 continue y1, x1, y2, x2 = PS[i_] line_brg = math.degrees(math.atan2((x2 - x1) * kxc, y2 - y1)) % 180 h_ok = np.abs((AZ - line_brg) % 180 - 90) <= args.perp_tol line_pen = None dmins = None if LP is not None: if sub is None or not len(sub): skipped["no_line"] += 1 continue by = (sub[:, 0] - clat) * 110540 bx = (sub[:, 1] - clon) * 111320 * math.cos(math.radians(clat)) bd = np.hypot(bx, by) baz = np.degrees(np.arctan2(bx, by)) % 360 off = (baz[None, :] - AZ[:, None] + 180) % 360 - 180 # (naz, n points) far = bd > args.line_min dmins = [] for lo_, hi_ in wins: mm = (off > lo_) & (off < hi_) & far[None, :] dmins.append(np.where(mm, bd[None, :], np.inf).min(axis=1)) line_pen = np.zeros(naz) for dm, (dlo, dhi), sc in zip(dmins, dwin, scales): line_pen += np.maximum(0, dlo - dm) / sc + np.maximum(0, dm - dhi) / sc if args.line_order == "asc": line_pen += ((dmins[0] >= dmins[1]) | (dmins[1] >= dmins[2])) * 1.0 elif args.line_order == "desc": line_pen += ((dmins[0] <= dmins[1]) | (dmins[1] <= dmins[2])) * 1.0 if not np.isfinite(line_pen).any(): skipped["no_line"] += 1 continue best = None def idx(off): return (HS[:, None] + np.round(off / az_step).astype(int)[None, :]) % naz nhs = {D1: ang[:, dist_v <= D1].max(axis=1) if D1 else None for D1 in near_dists} # near layer: highest angle within D1 m for D1, fs in ((d_, f_) for d_ in near_dists for f_ in fscales): nh = nhs[D1] f = f0 * fs r_off = np.degrees(np.arctan((RX - cx) / f)) r_el = np.degrees(np.arctan((hrow - RY) / f)) # solve horizon offset cc (pitch/hrow inaccurate) and roll rr together by least squares: a 1° handheld tilt puts the ridgeline at both ends of the frame # off by a dozen-plus pixels; left unsolved, the ground truth gets crowded into the same rms tier as a pile of wrong candidates (one real photo: 11.1 → 6.2 px, only then did the ground truth stand out) diff = hor[idx(r_off)] - r_el[None, :] if NX.size: # near points share cc and roll with the far ridge n_off = np.degrees(np.arctan((NX - cx) / f)) diff = np.concatenate([diff, nh[idx(n_off)] - np.degrees(np.arctan((hrow - NY) / f))[None, :]], axis=1) r_off = np.concatenate([r_off, n_off]) if roll_max > 0 and (r_off != r_off.mean()).any(): oc = r_off - r_off.mean() dmean = diff.mean(axis=1) rr = np.clip(((diff - dmean[:, None]) * oc[None, :]).sum(axis=1) / (oc ** 2).sum(), -roll_max, roll_max) cc = np.clip(dmean - rr * r_off.mean(), -args.cc_max, args.cc_max) else: # --roll-max 0: like the old version, subtract only the median rr = np.zeros(naz) cc = np.clip(np.median(diff, axis=1), -args.cc_max, args.cc_max) res = diff - cc[:, None] - rr[:, None] * r_off[None, :] rms = np.sqrt(np.mean(res ** 2, axis=1)) rms_near = np.sqrt(np.mean(res[:, RX.size:] ** 2, axis=1)) if NX.size else None if FLX.size: fl_off = np.degrees(np.arctan((FLX - cx) / f)) pen = np.mean(np.clip(hor[idx(fl_off)] - cc[:, None] - args.flat_clear, 0, None), axis=1) else: pen = np.zeros(naz) if NCX.size: # near-clear columns: the near layer must stay below the given row c_off = np.degrees(np.arctan((NCX - cx) / f)) c_el = np.degrees(np.arctan((hrow - NCR) / f)) ncpen = np.mean(np.clip(nh[idx(c_off)] - cc[:, None] - rr[:, None] * c_off[None, :] - c_el[None, :], 0, None), axis=1) else: ncpen = np.zeros(naz) # across a wide focal ladder, compare in pixels (× fs, i.e. degrees at the base focal): in degrees the same pixel mismatch shrinks as f grows, and on a # real photo every candidate ran to the longest rung. Within ×0.9–1.12 degrees stay: there pixels pushed focal short and camera positions farther score = (rms + args.flat_w * pen + args.near_clear_w * ncpen) * (fs if px_metric else 1.0) total = score + args.line_w * line_pen if line_pen is not None else score if h_ok is not None: total = np.where(h_ok, total, np.inf) k = int(np.argmin(total)) if not np.isfinite(total[k]): continue if best is None or total[k] < best["total"]: best = {"hit": c["src_idx"], "name": name, "hit_ll": [round(lat, 5), round(lon, 5)], "cam": [round(clat, 5), round(clon, 5)], "d": round(math.hypot(dx, dy)), "brg": round(math.degrees(math.atan2(dx, dy)) % 360), "g": round(g0, 1), "H": round(k * az_step, 2), "fs": fs, "f": round(f, 1), "f35": round(f35_0 * fs, 1), "cc": round(float(cc[k]), 2), "roll": round(math.degrees(math.atan(float(rr[k]))), 2), "rms": round(float(rms[k]), 3), "rms_px": round(float(rms[k]) * f * math.pi / 180, 1), "flatpen": round(float(pen[k]), 3), "score": round(float(score[k]), 3)} if D1: best.update({"D1": D1, "nclearpen": round(float(ncpen[k]), 3)}) if rms_near is not None: best["rms_near_px"] = round(float(rms_near[k]) * f * math.pi / 180, 1) if line_brg is not None: best["line_brg"] = round(line_brg, 1) if line_pen is not None: best.update({"dL": round(float(dmins[0][k])), "dC": round(float(dmins[1][k])), "dR": round(float(dmins[2][k])), "line_pen": round(float(line_pen[k]), 3)}) best["total"] = round(float(total[k]), 3) if best is None: skipped["no_line"] += 1 continue recs.append(best) n_c += 1 if best_c is None or best["total"] < best_c["total"]: best_c = best clusters_out.append({"hit": c["src_idx"], "name": name, "hit_ll": [round(lat, 5), round(lon, 5)], "n": c.get("n"), "max_ang": c.get("max_ang"), "n_cams": n_c, "best": best_c}) print(f"cluster {ci + 1}/{len(centers)} hit{c['src_idx']} {name or '-'}: scored {n_c} camera positions" + (f", best total {best_c['total']} H {best_c['H']} cam {best_c['cam']}" if best_c else ", no camera position passed the filters"), file=sys.stderr) recs.sort(key=lambda r: r["total"]) clusters_out.sort(key=lambda x: x["best"]["total"] if x["best"] else float("inf")) for i, x in enumerate(clusters_out): x["rank"] = i + 1 hfov = 2 * math.degrees(math.atan(cx / f0)) lead = [x["best"] for x in clusters_out if x["best"]] if len(clusters_out) > 1 else recs lead = lead[:10] focal_edge = None if len(fscales) > 1 and lead: lo_s, hi_s = min(fscales), max(fscales) n_long = sum(r["fs"] == hi_s for r in lead) n_short = sum(r["fs"] == lo_s for r in lead) top1 = "long" if lead[0]["fs"] == hi_s else "short" if lead[0]["fs"] == lo_s else None if top1 or max(n_long, n_short) * 2 >= len(lead): focal_edge = {"top1": top1, "long": n_long, "short": n_short, "of": len(lead), "range_f35": [round(f35_0 * lo_s, 1), round(f35_0 * hi_s, 1)]} out = { "params": {"hits": args.hits, "at": args.at, "select": args.select, "ridge": str(args.ridge), "radius_m": args.radius, "grid_m": args.grid, "zoom": args.zoom, "focal_scales": fscales, "az_step_deg": az_step, "near_m": args.near, "range_m": args.range, "nsamp": args.nsamp, "eye_m": args.eye, "focal": focal_how, "focal_metric": "px" if px_metric else "deg", "f35_ladder": [round(f35_0 * x, 1) for x in fscales], "near_dist_m": near_dists if two else None, "near_clear_w": args.near_clear_w, "perp_line": args.perp_line, "perp_tol_deg": args.perp_tol if args.perp_line else None, "cam_flat_m": args.cam_flat, "cam_flat_radius_m": args.cam_flat_radius, "min_peak_deg": args.min_peak, "cc_max_deg": args.cc_max, "roll_max_deg": args.roll_max, "flat_clear_deg": args.flat_clear, "flat_w": args.flat_w, "flat_step_px": args.flat_step, "line": args.line, "line_dist": args.line_dist, "line_win": args.line_win if args.line else None, "line_scale": args.line_scale if args.line else None, "line_order": args.line_order if args.line else None, "line_w": args.line_w, "line_min_m": args.line_min, "line_sample_m": args.line_sample, "line_reach_m": args.line_reach, "photo": {"f0_px": f0, "hrow": hrow, "cx": cx, "hfov_deg_at_fs1": round(hfov, 1), "n_ridge": int(RX.size), "flat": flat}}, "n_clusters": len(clusters_out), "n_cams": len(recs), "n_skipped": skipped, "dem_failed_clusters": dem_failed, "focal_edge": focal_edge, "clusters": clusters_out, "cams": recs[:args.keep], } Path(args.out).write_text(json.dumps(out, ensure_ascii=False, indent=0), encoding="utf-8") print(f"clusters {len(clusters_out)}, camera positions {len(recs)}, skipped {skipped} → {ar -
tiles.py 15 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = ["pillow"] # /// """Download satellite tiles, stitch them into one large image (mosaic), and mark points on it. A .json with the same name is written next to the mosaic (zoom level, top-left tile index, center point); evidence.py and the mark subcommand use it to convert lat/lon to image pixels. Google satellite imagery is the default (WGS84, the same coordinate system as GPS, no offset correction needed in China). Examples: tiles.py fetch 22.6050,114.0540 --zoom 18 --radius 4 --out area.jpg tiles.py mark area.jpg --points panos.json --out area_panos.jpg --label tiles.py mark area.jpg --points cands.json --geojson power.geojson --geojson rail.geojson \ --sector 22.6045,114.0520,265,40,3000 --out area_lines.jpg # overlay line features + camera-position view sector tiles.py sheet --points cands.json --zoom 18 --out cands_sheet.jpg # one centered thumbnail per candidate point, numbered tiles.py px2ll area.jpg --px 812,440 --px 300,95 # mosaic pixels → lat/lon (for a cropped image add --crop x0,y0 --scale s) tiles.py sheet --grid <s,w,n,e> --zoom 17 --size 320 --cols 5 --out town.jpg # tile a whole urban area with a grid and look cell by cell (find running tracks, factory buildings) """ from __future__ import annotations import argparse from _net import curl_args, PROXY_HELP import json import os import re import subprocess import sys from concurrent.futures import ThreadPoolExecutor from pathlib import Path from PIL import Image, ImageDraw sys.path.insert(0, str(Path(__file__).parent)) import geo # noqa: E402 SOURCES = { "google": "https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}", "esri": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", } def _get(url: str, path: Path, proxy: str | None) -> bool: if path.exists() and path.stat().st_size > 1000: return True cmd = ["curl", "-q", "-s", "-m", "60", "-o", str(path), url] cmd += curl_args(proxy) subprocess.run(cmd, check=False) return path.exists() and path.stat().st_size > 1000 def fetch(center: tuple[float, float], zoom: int, radius: int, out: Path, source: str, proxy: str | None, cache: Path) -> dict: cache.mkdir(parents=True, exist_ok=True) gx, gy = geo.ll2px(zoom, *center) cx, cy = int(gx // 256), int(gy // 256) xs = range(cx - radius, cx + radius + 1) ys = range(cy - radius, cy + radius + 1) jobs = [(x, y) for x in xs for y in ys] def job(t): x, y = t p = cache / f"{source}_{zoom}_{x}_{y}.jpg" ok = _get(SOURCES[source].format(x=x, y=y, z=zoom), p, proxy) return t, p, ok img = Image.new("RGB", (256 * len(xs), 256 * len(ys)), "black") failed = 0 with ThreadPoolExecutor(16) as ex: for (x, y), p, ok in ex.map(job, jobs): if not ok: failed += 1 continue try: img.paste(Image.open(p), ((x - xs[0]) * 256, (y - ys[0]) * 256)) except Exception: failed += 1 img.save(out, quality=90) meta = {"zoom": zoom, "origin_tile": [xs[0], ys[0]], "center": list(center), "source": source, "size": list(img.size), "m_per_px": geo.meters_per_px(zoom, center[0]), "failed_tiles": failed} out.with_suffix(".json").write_text(json.dumps(meta, indent=2), encoding="utf-8") return meta class Mosaic: """lat/lon ↔ mosaic pixels.""" def __init__(self, image: Path): self.meta = json.loads(Path(image).with_suffix(".json").read_text(encoding="utf-8")) self.z = self.meta["zoom"] self.ox, self.oy = self.meta["origin_tile"] def to_px(self, lat: float, lon: float) -> tuple[float, float]: x, y = geo.ll2px(self.z, lat, lon) return x - self.ox * 256, y - self.oy * 256 def to_ll(self, x: float, y: float) -> tuple[float, float]: return geo.px2ll(self.z, x + self.ox * 256, y + self.oy * 256) PALETTE = ["yellow", "cyan", "magenta", "lime", "orange", "white", "red", "deepskyblue"] LINE_COLORS = ["orange", "deepskyblue", "magenta", "lime", "white", "red"] def _draw_geojson(d: ImageDraw.ImageDraw, m: "Mosaic", gj: dict, color: str) -> int: """Draw GeoJSON lines, polygons and points on the mosaic (line width 3, polygons as outlines only). Returns how many features were drawn.""" n = 0 for ft in gj.get("features", []): g = ft.get("geometry") or {} t, cs = g.get("type"), g.get("coordinates") if not cs: continue lines = {"LineString": [cs], "MultiLineString": cs, "Polygon": cs, "MultiPolygon": [ring for poly in cs for ring in poly]}.get(t) if lines is not None: for ln in lines: xy = [m.to_px(c[1], c[0]) for c in ln] if len(xy) >= 2: d.line(xy, fill=color, width=3) n += 1 elif t == "Point": x, y = m.to_px(cs[1], cs[0]) d.rectangle([x - 3, y - 3, x + 3, y + 3], outline=color, width=2) n += 1 return n def _draw_sector(d: ImageDraw.ImageDraw, m: "Mosaic", spec: str) -> None: """lat,lon,heading,horizontal FOV,radius m → camera-position view sector.""" lat, lon, hd, fov, rng = map(float, spec.split(",")) pts = [m.to_px(lat, lon)] steps = max(4, int(fov // 3)) for k in range(steps + 1): a = hd - fov / 2 + fov * k / steps pts.append(m.to_px(*geo.dest((lat, lon), a, rng))) d.line(pts + [pts[0]], fill="yellow", width=3) mid = m.to_px(*geo.dest((lat, lon), hd, rng)) d.line([pts[0], mid], fill="yellow", width=1) x, y = pts[0] d.ellipse([x - 7, y - 7, x + 7, y + 7], fill="red", outline="yellow", width=2) def mark(image: Path, points: dict, out: Path, label: bool, geojsons: list[Path] | None = None, sectors: list[str] | None = None) -> None: """points: {name: [lat, lon]} (wgs). Colored by the name prefix before the first space; with --label, writes the first 12 characters of the name.""" m = Mosaic(image) img = Image.open(image).convert("RGB") d = ImageDraw.Draw(img) for i, gp in enumerate(geojsons or []): n = _draw_geojson(d, m, json.loads(Path(gp).read_text(encoding="utf-8")), LINE_COLORS[i % len(LINE_COLORS)]) print(f"{gp}: {n} features, color {LINE_COLORS[i % len(LINE_COLORS)]}") for sp in sectors or []: _draw_sector(d, m, sp) groups: dict[str, str] = {} for name, ll in points.items(): lat, lon = ll[0], ll[1] prefix = str(name).split(" ")[0].rstrip("0123456789") or "_" color = groups.setdefault(prefix, PALETTE[len(groups) % len(PALETTE)]) x, y = m.to_px(lat, lon) d.ellipse([x - 5, y - 5, x + 5, y + 5], fill=color, outline="black") if label: d.text((x + 7, y - 6), str(name)[:12], fill=color) img.save(out, quality=90) def grid_points(bbox: str, zoom: int, size: int, step: float | None) -> dict: """Tile s,w,n,e into grid points; default spacing = 90% of the width each cell covers.""" import math s, w, n, e = (float(v) for v in bbox.split(",")) mpp = 156543.03392 * math.cos(math.radians((s + n) / 2)) / 2 ** zoom step = step or size * mpp * 0.9 dlat, dlon = step / 110574, step / (111320 * math.cos(math.radians((s + n) / 2))) pts, r, lat = {}, 0, n - dlat / 2 while lat > s: c, lon = 0, w + dlon / 2 while lon < e: pts[f"r{r:02d}c{c:02d}"] = [round(lat, 6), round(lon, 6)] c, lon = c + 1, lon + dlon r, lat = r + 1, lat - dlat return pts def sheet(points: dict, zoom: int, size: int, cols: int, out: Path, source: str, proxy: str | None, cache: Path) -> list[Path]: """One size×size satellite thumbnail centered on each candidate point, with number and name, laid out on one page (more pages automatically when there are too many).""" cache.mkdir(parents=True, exist_ok=True) items = list(points.items()) per = cols * cols pages = [] for pi in range(0, len(items), per): chunk = items[pi:pi + per] rows = (len(chunk) + cols - 1) // cols S = Image.new("RGB", (cols * size, rows * size), "black") dr = ImageDraw.Draw(S) for k, (name, ll) in enumerate(chunk): gx, gy = geo.ll2px(zoom, ll[0], ll[1]) x0, y0 = gx - size / 2, gy - size / 2 tile = Image.new("RGB", (size, size), "gray") for tx in range(int(x0 // 256), int((x0 + size) // 256) + 1): for ty in range(int(y0 // 256), int((y0 + size) // 256) + 1): p = cache / f"{source}_{zoom}_{tx}_{ty}.jpg" if _get(SOURCES[source].format(x=tx, y=ty, z=zoom), p, proxy): try: tile.paste(Image.open(p), (int(tx * 256 - x0), int(ty * 256 - y0))) except Exception: # noqa: BLE001 pass cx, cy = (k % cols) * size, (k // cols) * size S.paste(tile, (cx, cy)) c = size / 2 dr.line([cx + c - 12, cy + c, cx + c + 12, cy + c], fill="red", width=2) dr.line([cx + c, cy + c - 12, cx + c, cy + c + 12], fill="red", width=2) dr.rectangle([cx, cy, cx + size, cy + 20], fill="black") dr.text((cx + 4, cy + 3), f"#{pi + k + 1} {str(name)[:28]}", fill="yellow") o = out if pi == 0 else out.with_name(f"{out.stem}_{pi // per + 1}{out.suffix}") S.save(o, quality=88) pages.append(o) return pages def _neg_coords(argv: list[str]) -> list[str]: """argparse treats negative coordinates like -1.45,-48.5 as option names; prefixing a space makes them plain values (float ignores the space). Needed for every puzzle in the southern or western hemisphere.""" return [" " + a if re.match(r"^-\d[\d.]*(,-?[\d.]+)+$", a) else a for a in argv] def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = ap.add_subparsers(dest="cmd", required=True) f = sub.add_parser("fetch") f.add_argument("center", help="lat,lon (wgs)") f.add_argument("--zoom", type=int, default=18, help="17≈1.1m/px for an area, 19≈0.28m/px for a single building") f.add_argument("--radius", type=int, default=4, help="rings of tiles around the center tile, 4 → 9x9 tiles") f.add_argument("--out", type=Path, required=True) f.add_argument("--source", choices=list(SOURCES), default="google") f.add_argument("--proxy", default=os.environ.get("GEO_PROXY"), help=PROXY_HELP) f.add_argument("--cache", type=Path, default=Path(".geo-cache/tiles")) mk = sub.add_parser("mark") mk.add_argument("image", type=Path) mk.add_argument("--points", type=Path, help="JSON: {name: [lat, lon]}") mk.add_argument("--out", type=Path, required=True) mk.add_argument("--label", action="store_true") mk.add_argument("--geojson", type=Path, action="append", help="overlay GeoJSON (output of osm.py geom), repeatable, one color per file") mk.add_argument("--sector", action="append", help="camera-position view sector lat,lon,heading,horizontal FOV,radius m, repeatable") sh = sub.add_parser("sheet", help="centered satellite thumbnail for each candidate point, laid out as a numbered comparison page") shg = sh.add_mutually_exclusive_group(required=True) shg.add_argument("--points", type=Path, help="JSON {name: [lat, lon]}") shg.add_argument("--grid", help="s,w,n,e: cover an area with a grid and render cell by cell (cell name r<row>c<col>: r00 northernmost, c00 westernmost; center coordinates written to <out>.cells.json)") sh.add_argument("--step", type=float, help="--grid cell spacing (meters); default leaves 10%% overlap of the area each cell covers") sh.add_argument("--zoom", type=int, default=18) sh.add_argument("--size", type=int, default=320, help="pixels per cell") sh.add_argument("--cols", type=int, default=4, help="cols×cols cells per page") sh.add_argument("--out", type=Path, required=True) sh.add_argument("--source", choices=list(SOURCES), default="google") sh.add_argument("--proxy", default=os.environ.get("GEO_PROXY"), help=PROXY_HELP) sh.add_argument("--cache", type=Path, default=Path(".geo-cache/tiles")) pl = sub.add_parser("px2ll", help="pixels on a mosaic (or a cropped/scaled copy of it) → lat/lon") pl.add_argument("image", type=Path, help="mosaic from tiles.py fetch (needs the same-name .json next to it)") pl.add_argument("--px", action="append", required=True, help="x,y, repeatable; if you measured on a cropped/scaled image, pair with --crop/--scale") pl.add_argument("--crop", default="0,0", help="top-left x0,y0, in the original mosaic, of the image you measured pixels on") pl.add_argument("--scale", type=float, default=1.0, help="scale of that image relative to the original mosaic (0.5 for half size)") pl.add_argument("--out", type=Path, help="write {p1: [lat, lon], ...}") args = ap.parse_args(_neg_coords(sys.argv[1:])) if args.cmd == "px2ll": m = Mosaic(args.image) x0, y0 = (float(v) for v in args.crop.split(",")) pts = {} for i, s in enumerate(args.px, 1): x, y = (float(v) for v in s.split(",")) lat, lon = m.to_ll(x0 + x / args.scale, y0 + y / args.scale) pts[f"p{i}"] = [round(lat, 6), round(lon, 6)] print(f"p{i} ({x:.0f},{y:.0f}) → {lat:.6f},{lon:.6f}") if args.out: args.out.write_text(json.dumps(pts, indent=1), encoding="utf-8") print(f"-> {args.out}") return if args.cmd == "sheet": pts = json.loads(args.points.read_text(encoding="utf-8")) if args.points else grid_points(args.grid, args.zoom, args.size, args.step) if args.grid: args.out.with_suffix(".cells.json").write_text(json.dumps(pts, indent=1), encoding="utf-8") print(f"grid: {len(pts)} cells (cell name r<row>c<col>, rows north to south, columns west to east; cell center coordinates -> {args.out.with_suffix('.cells.json')})") for p in sheet(pts, args.zoom, args.size, args.cols, args.out, args.source, args.proxy, args.cache): print(p) return if args.cmd == "fetch": lat, lon = map(float, args.center.split(",")) meta = fetch((lat, lon), args.zoom, args.radius, args.out, args.source, args.proxy, args.cache) print(json.dumps(meta)) elif args.cmd == "mark": if not (args.points or args.geojson or args.sector): ap.error("mark needs at least one of --points, --geojson, --sector") mark(args.image, json.loads(args.points.read_text(encoding="utf-8")) if args.points else {}, args.out, args.label, args.geojson, args.sector) print(args.out) if __name__ == "__main__": # Chinese-locale Windows writes GBK by default: m², ñ make it crash, and the Chinese the agent reads comes out garbled sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace") main() -
_browser.py 1.2 KB
"""Shared Chrome / Playwright Chromium launch and actionable setup errors.""" from __future__ import annotations from _net import resolve_proxy async def launch_browser(playwright, proxy: str | None = None): value = resolve_proxy(proxy) options = {"headless": True, "timeout": 15000, "args": ["--disable-blink-features=AutomationControlled"]} if value: options["proxy"] = {"server": value.replace("socks5h://", "socks5://")} else: options["args"].append("--no-proxy-server") errors = [] for channel in ("chrome", None): try: browser = await playwright.chromium.launch( **({"channel": channel} if channel else {}), **options) return browser, "Google Chrome" if channel else "Playwright Chromium" except Exception as exc: errors.append(f"{channel or 'chromium'}: {str(exc).splitlines()[0]}") raise RuntimeError( "Neither Google Chrome nor Playwright Chromium could start. " "Run `uvx playwright install chromium` and retry; on Linux, missing system libraries " "may require `uvx playwright install --with-deps chromium`. " + " | ".join(errors)) -
_device.py 1.1 KB
"""Shared torch device selection: CUDA, then MPS, then CPU; GEO_DEVICE overrides.""" from __future__ import annotations import os import sys DEVICES = ("cpu", "cuda", "mps") def _available(torch) -> dict: return {"cpu": True, "cuda": bool(torch.cuda.is_available()), "mps": bool(torch.backends.mps.is_available())} def torch_device(torch=None) -> str: """Device for the ML models. GEO_DEVICE=cpu|cuda|mps forces one; an invalid or unavailable value exits with one line.""" if torch is None: import torch have = _available(torch) want = os.environ.get("GEO_DEVICE", "").strip().lower() if not want: return "cuda" if have["cuda"] else "mps" if have["mps"] else "cpu" report = f"torch {torch.__version__} reports cuda={have['cuda']}, mps={have['mps']}" if want not in DEVICES: sys.exit(f"GEO_DEVICE={want!r} is not valid: use one of {', '.join(DEVICES)}, or unset it for automatic selection ({report}).") if not have[want]: sys.exit(f"GEO_DEVICE={want} was requested but that device is not available: {report}. Unset GEO_DEVICE or install a torch build that supports it.") return want -
_image.py 12.2 KB
"""Shared reading of user photos and candidate images: an explicit set of raster formats, content sniffing instead of trusting the suffix, RAW rejected with a request to export, and one failure category per file. Scripts that read a photo or candidate images go through `open_image`; scripts that only read their own outputs (map tiles, panoramas) keep plain `Image.open`. Callers that import this declare `pillow-heif` in their PEP 723 header. Pixel coordinates (crop boxes, OCR boxes, ridge points, pose points) refer to the upright frame, the photo as an image viewer shows it: `open_photo` / `upright` apply the EXIF orientation exactly once. Records a script writes with pixel coordinates in them (ridge.json, a pose record's `_frame`) say so with "frame": "upright". A record without the key was written before orientation was applied, when those scripts drew on the stored pixels; it is read in that "stored" frame whatever the image size (`frame_of`), so old records give the overlays they gave then. """ from __future__ import annotations import sys from pathlib import Path from PIL import Image, ImageOps, features try: import pillow_heif pillow_heif.register_heif_opener() HEIF = True except ImportError: # without pillow-heif, HEIC/HEIF is reported as a missing decoder; other formats work as usual HEIF = False # Pillow format names accepted as a photo or candidate; anything else Pillow can open is "unsupported" SUPPORTED = {"JPEG", "MPO", "PNG", "WEBP", "GIF", "BMP", "TIFF", "HEIF", "AVIF"} # Directory scans: these suffixes (any case) after the historical *.jp*g then *.png order; other files are sniffed SCAN_SUFFIXES = (".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif", ".avif") RAW_SUFFIXES = {".3fr", ".arw", ".cr2", ".cr3", ".crw", ".dcr", ".dng", ".erf", ".iiq", ".k25", ".kdc", ".mef", ".mos", ".mrw", ".nef", ".nrw", ".orf", ".pef", ".raf", ".raw", ".rw2", ".rwl", ".sr2", ".srf", ".srw", ".x3f"} HEIF_BRANDS = {b"heic", b"heix", b"heim", b"heis", b"hevc", b"hevx", b"heif", b"mif1", b"msf1"} AVIF_BRANDS = {b"avif", b"avis"} HEAD = 32 # bytes read to sniff a file ORIENTATION = 0x0112 class ImageReadError(Exception): """category: unreadable | empty | raw | not-image | unsupported | missing-decoder | decode-error""" def __init__(self, category: str, reason: str): super().__init__(reason) self.category = category self.reason = reason def sniff(head: bytes, suffix: str = "") -> str | None: """Format from the first bytes: JPEG, PNG, WEBP, GIF, BMP, TIFF, HEIF, AVIF, RAW or TEXT; None if unknown.""" if head.startswith(b"\xff\xd8\xff"): return "JPEG" if head.startswith(b"\x89PNG\r\n\x1a\n"): return "PNG" if head[:4] == b"RIFF" and head[8:12] == b"WEBP": return "WEBP" if head[:6] in (b"GIF87a", b"GIF89a"): return "GIF" if head[:2] == b"BM" and len(head) >= 14: return "BMP" if head[4:8] == b"ftyp": brand = head[8:12] if brand in HEIF_BRANDS: return "HEIF" if brand in AVIF_BRANDS: return "AVIF" if brand == b"crx ": return "RAW" # Canon CR3 if head.startswith((b"FUJIFILMCCD-RAW", b"IIRO", b"IIRS", b"MMOR", b"IIU\x00", b"FOVb")): return "RAW" # Fujifilm RAF, Olympus ORF, Panasonic RW2, Sigma X3F if head[:4] in (b"II*\x00", b"MM\x00*"): if head[8:10] == b"CR" or suffix.lower() in RAW_SUFFIXES: return "RAW" # Canon CR2; DNG, NEF, ARW, PEF ... are TIFF inside return "TIFF" if head.lstrip()[:1] in (b"<", b"{", b"["): return "TEXT" return None def _head(path: Path) -> bytes: try: with open(path, "rb") as fh: return fh.read(HEAD) except OSError as e: raise ImageReadError("unreadable", f"cannot read the file ({e.strerror or type(e).__name__})") from None def _tiff_is_raw(im: Image.Image) -> bool: """A TIFF that is really a camera RAW: a DNG version tag, raw photometric data, or a first image marked as a reduced-resolution preview with the real image in SubIFDs (Pillow would only ever read the preview).""" tags = getattr(im, "tag_v2", {}) preview = int(tags.get(254, 0) or 0) & 1 return 50706 in tags or tags.get(262) in (32803, 34892) or bool(preview and 330 in tags) def _first_line(e: Exception) -> str: text = str(e).strip().splitlines() return f"{type(e).__name__}: {text[0]}" if text else type(e).__name__ def open_image(path: Path | str, load: bool = True) -> Image.Image: """Open a photo or candidate image. Raises ImageReadError with a category and a one-line reason. load=True decodes the pixels now, so a damaged file fails here instead of in the middle of a run.""" path = Path(path) head = _head(path) if not head: raise ImageReadError("empty", "empty file") kind = sniff(head, path.suffix) if kind == "RAW" or (kind is None and path.suffix.lower() in RAW_SUFFIXES): raise ImageReadError("raw", f"camera RAW ({path.suffix or 'no suffix'}), which is not decoded here: export it as JPEG or PNG first") if kind == "TEXT": raise ImageReadError("not-image", "text, not an image (probably an error page saved by a download)") if kind == "HEIF" and not HEIF: raise ImageReadError("missing-decoder", "HEIC/HEIF, which needs pillow-heif (run the script with uv run, or convert to JPEG/PNG)") if kind == "AVIF" and not features.check("avif"): raise ImageReadError("missing-decoder", "AVIF, which this Pillow build cannot decode (upgrade Pillow, or convert to JPEG/PNG)") try: im = Image.open(path) if im.format == "TIFF" and _tiff_is_raw(im): # checked from the tags, before any pixels are decoded im.close() raise ImageReadError("raw", f"camera RAW ({path.suffix or 'no suffix'}, TIFF-based: only its embedded preview would be read), " "which is not decoded here: export it as JPEG or PNG first") if load: im.load() except ImageReadError: raise except Exception as e: # noqa: BLE001 if kind is None: raise ImageReadError("not-image", f"not a recognised image ({_first_line(e)})") from None raise ImageReadError("decode-error", f"{kind} that could not be decoded ({_first_line(e)})") from None if im.format not in SUPPORTED: fmt = im.format im.close() raise ImageReadError("unsupported", f"{fmt} is not a supported photo format (JPEG, PNG, WebP, HEIC/HEIF, AVIF, GIF, BMP, TIFF): convert it first") return im def open_or_exit(path: Path | str, what: str = "photo", load: bool = True) -> Image.Image: """open_image for a single required input: exit with one line instead of a traceback.""" try: return open_image(path, load=load) except ImageReadError as e: sys.exit(f"cannot read {what} {path}: {e.reason}") def find_images(directory: Path | str) -> list[Path]: """Image files in a folder, not recursive. Keeps the historical order (*.jp*g, then *.png, each sorted), then adds other image suffixes in any case (WebP, HEIC/HEIF, AVIF, uppercase names), then files whose first bytes show an image or a camera RAW despite a missing or misleading suffix (RAW is kept so it can be reported).""" d = Path(directory) files = sorted(d.glob("*.jp*g")) + sorted(d.glob("*.png")) seen = {f.resolve() for f in files} rest = sorted(f for f in d.iterdir() if f.is_file() and f.resolve() not in seen) by_suffix = [f for f in rest if f.suffix.lower() in SCAN_SUFFIXES or f.suffix.lower() in RAW_SUFFIXES] sniffed = [] for f in rest: if f in by_suffix: continue try: with open(f, "rb") as fh: kind = sniff(fh.read(HEAD), f.suffix) except OSError: continue if kind not in (None, "TEXT"): sniffed.append(f) return files + by_suffix + sniffed def orientation(im: Image.Image) -> int: """EXIF orientation (1–8) still to apply to the decoded pixels. HEIF is always 1: libheif applies the file's irot/imir transforms and pillow-heif resets the EXIF tag, which the HEIF spec says to ignore.""" if im.format == "HEIF": return 1 try: o = int(im.getexif().get(ORIENTATION, 1) or 1) except Exception: # noqa: BLE001 return 1 return o if 1 <= o <= 8 else 1 def upright(im: Image.Image) -> Image.Image: """The image as a viewer shows it. Returns the same object when there is nothing to apply; otherwise exif_transpose, whose result carries no orientation tag, so a second call changes nothing.""" return im if orientation(im) == 1 else ImageOps.exif_transpose(im) FRAMES = ("upright", "stored") def frame_of(record: dict, default: str = "stored") -> str: """Frame of the pixel coordinates in a record: its "frame" value, else `default`. For records a script wrote (ridge.json, a pose record's _frame) the default "stored" is the frame every record without the key was written in.""" frame = record.get("frame", default) if frame not in FRAMES: sys.exit(f'"frame" must be one of {", ".join(FRAMES)}, not {frame!r}') return frame def open_photo(path: Path | str, what: str = "photo", frame: str = "upright", size=None) -> Image.Image: """open_or_exit, in the frame of the coordinates that will be drawn on it: "upright" (EXIF orientation applied) or "stored" (the decoded pixels as they are, the frame of records written before orientation was applied). size: the image size a record gives, checked against the photo in that frame (records may be scaled, so the aspect ratio is what must agree).""" im = open_or_exit(path, what) o = orientation(im) if frame == "stored": if o != 1: print(f"note: {path} has EXIF orientation {o}; this record's coordinates are in the stored frame (a record without " f"\"frame\": \"upright\" was written before orientation was applied), so it is drawn on the stored " f"{im.width}x{im.height} pixels, unrotated", file=sys.stderr) else: im = upright(im) if size is not None and size[0] > 0 and size[1] > 0 and abs(size[0] / size[1] - im.width / im.height) > 0.01 * im.width / im.height: print(f"warning: the record's image size {size[0]}x{size[1]} doesn't match {path} in the {frame} frame " f"({im.width}x{im.height}); its points won't line up with the photo. If they were measured in the other frame, " f"set \"frame\": \"{'stored' if frame == 'upright' else 'upright'}\" in the record", file=sys.stderr) return im # Formats every later step and the search engines take as they are; anything else gets a working copy NATIVE = {"JPEG", "MPO", "PNG"} def working_copy_reason(im: Image.Image) -> str | None: """Why a photo needs a working copy, or None: a format outside NATIVE, or an EXIF orientation still to apply.""" if im.format not in NATIVE: return f"{'HEIC/HEIF' if im.format == 'HEIF' else im.format} decoded to PNG" o = orientation(im) return f"EXIF orientation {o} applied" if o != 1 else None def write_working_copy(path: Path | str, out: Path | str) -> dict | None: """Lossless upright PNG of a photo that needs one (see working_copy_reason), with the original's EXIF kept (focal length, time, GPS) and its orientation tag reset. Returns None for photos used as they are; raises ImageReadError for unreadable ones. The original file is never touched.""" path, out = Path(path), Path(out) im = open_image(path) why = working_copy_reason(im) if why is None: return None up = upright(im) if up.mode not in ("RGB", "RGBA", "L", "LA"): up = up.convert("RGBA" if "A" in up.getbands() else "RGB") exif = im.getexif() if ORIENTATION in exif: exif[ORIENTATION] = 1 out.parent.mkdir(parents=True, exist_ok=True) extra = {"icc_profile": im.info["icc_profile"]} if im.info.get("icc_profile") else {} up.save(out, format="PNG", exif=exif, **extra) return {"original": str(path), "working": str(out), "reason": why, "format": im.format, "original_size": list(im.size), "working_size": list(up.size)} -
_net.py 1.6 KB
"""HTTP helpers shared by service clients.""" from __future__ import annotations import os import subprocess PROXY_HELP = "proxy URL or 'direct'" def resolve_proxy(proxy: str | None = None) -> str | None: value = (os.environ.get("GEO_PROXY", "") if proxy is None else proxy).strip() return None if value.lower() in ("", "direct") else value def curl_args(proxy: str | None = None) -> list[str]: value = resolve_proxy(proxy) # Clear NO_PROXY for an explicit proxy; ignore ambient proxies for direct mode. return ["--proxy", value, "--noproxy", ""] if value else ["--noproxy", "*"] def fetch_bytes(url: str, proxy: str | None = None, timeout: int = 20, headers: dict[str, str] | None = None) -> bytes: cmd = ["curl", "-q", "-fsSL", "--max-time", str(timeout), *curl_args(proxy)] for name, value in (headers or {}).items(): cmd += ["-H", f"{name}: {value}"] result = subprocess.run(cmd + [url], capture_output=True, timeout=timeout + 5) if result.returncode: raise RuntimeError(f"Request failed (curl exit {result.returncode}); run doctor.py --network to check connectivity.") return result.stdout def model_proxy_env(proxy: str | None = None) -> None: """Set the model downloader's process environment before importing its HTTP client.""" value = resolve_proxy(proxy) for key in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"): os.environ.pop(key, None) if value: os.environ.update(HTTP_PROXY=value, HTTPS_PROXY=value, ALL_PROXY=value) else: os.environ["NO_PROXY"] = "*"
-
-
tests
-
test_board.py 9.9 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = [] # /// """Offline board regressions. Run: uv run skills/geo-sleuth/tests/test_board.py""" from __future__ import annotations import json from pathlib import Path import subprocess import sys import tempfile import unittest SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" sys.path.insert(0, str(SCRIPTS)) import board # noqa: E402 class CorridorPendingTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.root = Path(self.tmp.name) self.path = self.root / "board.json" self.output = self.root / "scan.json" self.output.write_text('{"hits": []}', encoding="utf-8") self.run_board("init", "--photo", "photo.jpg") def run_board(self, *args, ok=True): result = subprocess.run( [sys.executable, str(SCRIPTS / "board.py"), "--board", str(self.path), *args], cwd=self.root, capture_output=True, text=True, encoding="utf-8", ) if ok: self.assertEqual(result.returncode, 0, result.stdout + result.stderr) else: self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) return result def read_board(self): return json.loads(self.path.read_text(encoding="utf-8")) def clue(self, kind="infra", status="observed", *extra): self.run_board("clue", "A spatial observation or its computed test", "--kind", kind, "--status", status, *extra) def pending(self): return board._corridor_pending(self.read_board()) def test_alias_is_normalized_when_recorded(self): self.clue("infrastructure") self.assertEqual(self.read_board()["clues"]["K1"]["kind"], "infra") self.assertEqual(self.pending(), ["K1"]) self.assertIn("infrastructure clue K1", self.run_board("next").stdout) def test_legacy_alias_is_detected_without_rewriting_board(self): self.clue() data = self.read_board() data["clues"]["K1"]["kind"] = "infrastructure" self.path.write_text(json.dumps(data), encoding="utf-8") before = self.path.read_bytes() self.assertEqual(self.pending(), ["K1"]) self.assertIn("infrastructure clue K1", self.run_board("next").stdout) self.assertEqual(self.path.read_bytes(), before) def test_unlinked_computations_do_not_clear_infrastructure(self): self.clue() self.clue("infrastructure", "read") for kind in ("terrain", "corridor", "infra"): with self.subTest(kind=kind): self.clue(kind, "computed", "--file", str(self.output)) self.assertEqual(self.pending(), ["K1", "K2"]) def test_computation_clears_only_explicitly_linked_clues(self): self.clue() self.clue("infrastructure", "read") self.clue("terrain", "computed", "--file", str(self.output), "--resolves", "K1") self.assertEqual(self.pending(), ["K2"]) self.assertIn("infrastructure clue K2", self.run_board("next").stdout) self.assertEqual(self.read_board()["clues"]["K3"]["resolves"], ["K1"]) self.clue("corridor", "computed", "--file", str(self.output), "--resolves", "K1", "--resolves", "K2") self.assertEqual(self.pending(), []) self.assertNotIn("infrastructure clue", self.run_board("next").stdout) # Recording a computation is separate from scoring candidate evidence. self.assertEqual(self.read_board()["evidence"], []) def test_unrelated_explicit_link_does_not_clear_infrastructure(self): self.clue() self.clue("terrain") self.clue("terrain", "computed", "--file", str(self.output), "--resolves", "K2") self.assertEqual(self.pending(), ["K1"]) def test_invalid_completion_is_rejected_without_mutation(self): self.clue() before = self.path.read_bytes() cases = [ ("--status", "observed", "--file", str(self.output), "--resolves", "K1"), ("--status", "computed", "--file", str(self.output), "--resolves", "K99"), ("--status", "computed", "--file", str(self.output), "--resolves", "K1", "--resolves", "K99"), ("--status", "computed", "--resolves", "K1"), ("--status", "computed", "--file", str(self.root / "missing.json"), "--resolves", "K1"), ("--status", "computed", "--file", str(self.root), "--resolves", "K1"), ] for args in cases: with self.subTest(args=args): self.run_board("clue", "An invalid completion", "--kind", "terrain", *args, ok=False) self.assertEqual(self.path.read_bytes(), before) def test_missing_output_no_longer_counts_as_completion(self): self.clue() self.clue("terrain", "computed", "--file", str(self.output), "--resolves", "K1") self.assertEqual(self.pending(), []) self.output.unlink() self.assertEqual(self.pending(), ["K1"]) def test_relative_output_link_survives_a_different_working_directory(self): self.clue() self.clue("terrain", "computed", "--file", "scan.json", "--resolves", "K1") saved = self.read_board()["clues"]["K2"]["file"] self.assertEqual(Path(saved), self.output.resolve()) self.assertEqual(self.pending(), []) result = subprocess.run( [sys.executable, str(SCRIPTS / "board.py"), "--board", str(self.path), "next"], cwd=self.root.parent, capture_output=True, text=True, encoding="utf-8", ) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertNotIn("infrastructure clue", result.stdout) class LevelGapTests(unittest.TestCase): """A point added under the city instead of under its road: its evidence never reached the road ranking, and report named another road.""" def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.root = Path(self.tmp.name) self.path = self.root / "board.json" (self.root / "match.jpg").write_bytes(b"x") (self.root / "evidence.jpg").write_bytes(b"x") run = CorridorPendingTests.run_board self.run_board = lambda *a, ok=True: run(self, *a, ok=ok) self.run_board("init", "--photo", "photo.jpg") self.run_board("add", "City", "--level", "district") self.run_board("add", "A ST 100", "--level", "road", "--parent", "City", "--bbox", "49.20,-123.20,49.201,-123.19") self.run_board("add", "B ST 200", "--level", "road", "--parent", "City", "--bbox", "49.21,-123.20,49.211,-123.19") self.run_board("clue", "street view matches", "--kind", "municipal", "--status", "computed", "--file", "match.jpg") self.run_board("evidence", "--clue", "K1", "--for", "A ST 100:2", "--why", "partial", "--file", "match.jpg") def add_point(self, *extra): out = self.run_board("add", "spot", "--level", "point", "--parent", "City", "--bbox", "49.2159,-123.1095,49.2162,-123.1091", *extra) self.run_board("evidence", "--clue", "K1", "--for", "spot:10", "--why", "invariant features", "--file", "match.jpg") return out def ranking(self): return [r["name"] for r in board._scores(json.loads(self.path.read_text(encoding="utf-8")), "road", "uniform")] def test_skipped_level_is_flagged_everywhere(self): self.assertIn("2 road candidates", self.add_point().stdout) check = self.run_board("check") self.assertIn("FAIL spot (point) sits directly under City", check.stdout) self.assertIn("leaves the answer out", check.stdout) result = self.root / "result.json" result.write_text(json.dumps({"lat": 49.216, "lon": -123.1093, "radius_m": 20}), encoding="utf-8") report = self.run_board("report", "--merge", str(result)).stdout self.assertIn("outside the board's main answer A ST 100", report) self.assertTrue(json.loads(result.read_text(encoding="utf-8"))["board"]["level_gaps"]) def test_moving_under_its_road_carries_support_up(self): self.add_point() self.run_board("add", "W 60 AVE 100", "--level", "road", "--parent", "City", "--bbox", "49.2158,-123.1112,49.2164,-123.1075") self.assertIn("lies inside W 60 AVE 100", self.run_board("check", ok=True).stdout) self.run_board("move", "spot", "--parent", "W 60 AVE 100") check = self.run_board("check").stdout self.assertNotIn("FAIL", check) self.assertIn("main answer W 60 AVE 100", check) self.assertEqual(self.ranking()[0], "W 60 AVE 100") result = self.root / "result.json" result.write_text(json.dumps({"lat": 49.216, "lon": -123.1093}), encoding="utf-8") self.assertNotIn("WARN", self.run_board("report", "--merge", str(result)).stdout) def test_against_on_a_point_does_not_reach_its_road(self): self.run_board("add", "spot2", "--level", "point", "--parent", "B ST 200") before = self.ranking() self.run_board("evidence", "--clue", "K1", "--against", "spot2:0.1", "--why", "no match", "--file", "match.jpg") self.assertEqual(self.ranking(), before) b = json.loads(self.path.read_text(encoding="utf-8")) self.assertEqual(board._inherited(b, "B ST 200", set()), []) def test_same_clue_counts_once(self): self.run_board("add", "spot3", "--level", "point", "--parent", "A ST 100") self.run_board("evidence", "--clue", "K1", "--for", "spot3:10", "--why", "same clue", "--file", "match.jpg") b = json.loads(self.path.read_text(encoding="utf-8")) self.assertEqual(board._inherited(b, "A ST 100", {"K1"}), []) # A ST 100 already has its own K1 evidence def test_move_rejects_finer_parent(self): self.run_board("add", "spot4", "--level", "point", "--parent", "A ST 100") self.run_board("move", "A ST 100", "--parent", "spot4", ok=False) if __name__ == "__main__": unittest.main() -
test_regions.py 15.3 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = [] # /// """Offline regressions for region packs and country context. Run: uv run skills/geo-sleuth/tests/test_regions.py""" from __future__ import annotations import json import os from pathlib import Path import subprocess import sys import tempfile import unittest SKILL = Path(__file__).resolve().parents[1] SCRIPTS = SKILL / "scripts" sys.path.insert(0, str(SCRIPTS)) import regions # noqa: E402 # A fake second country whose area code 020 collides with China's, and whose 0120 covers two districts FIXTURE = { "region.json": {"code": "QQ", "name": "Testland", "names": ["Testland", "TL"], "status": "community", "docs": ["clues.md"], "admin_levels": {"1": "admin1", "2": "admin2"}, "lookups": {"area-code": {"file": "data/area_codes.json"}}}, "data/area_codes.json": {"_meta": {"kind": "area-code", "source": ["https://example.org/codes"], "fetched": "2026-10-08", "license": "CC0", "count": 3, "normalize": {"digits_only": True, "ensure_prefix": "0"}}, "entries": {"020": [{"admin1": "North", "admin2": "Porta"}], "0120": [{"admin1": "North", "admin2": "Alda"}, {"admin1": "North", "admin2": "Brin"}], "0431": [{"admin1": "吉林省", "admin2": "长春市", "district": "朝阳区"}]}}, "tests.json": [{"kind": "area-code", "value": "0120-555", "expect": [{"admin1": "North", "admin2": "Alda"}, {"admin1": "North", "admin2": "Brin"}]}], "clues.md": "# Testland\n\n## Signs\n\n### Blue signs\n- Look for: blue\n- Points to: North\n- Strength: weak\n- Counterexamples: none seen\n- Sources: test\n", } def write_pack(root: Path, files: dict) -> Path: d = root / "qq" for rel, body in files.items(): f = d / rel f.parent.mkdir(parents=True, exist_ok=True) f.write_text(body if isinstance(body, str) else json.dumps(body, ensure_ascii=False), encoding="utf-8") return d def run(script: str, *args: str, env: dict | None = None, cwd: Path | None = None) -> subprocess.CompletedProcess: return subprocess.run([sys.executable, str(SCRIPTS / script), *args], capture_output=True, text=True, encoding="utf-8", env={**os.environ, "PYTHONUTF8": "1", **(env or {})}, cwd=cwd) class BundledPacks(unittest.TestCase): def test_bundled_packs_pass_lint(self): r = run("regions.py", "lint") self.assertEqual(r.returncode, 0, r.stdout + r.stderr) def test_cn_lookups_keep_their_answers(self): m = regions.lookup("plate", "粤B·12345", "cn")["matches"] self.assertEqual([(x["admin1"], x.get("admin2")) for x in m], [("广东省", "深圳市")]) m = regions.lookup("area-code", "0817-1234567")["matches"] self.assertEqual([(x["country_code"], x["admin2"]) for x in m], [("CN", "南充市")]) def test_descriptions_are_not_places(self): # Wikipedia cells like "市区普通汽车" (city-area cars) used to become fake candidates m = regions.lookup("plate", "京A", "cn")["matches"] self.assertEqual(len(m), 1) self.assertEqual({k for k in regions.MATCH_LEVELS if m[0].get(k)}, {"admin1"}) def test_unknown_country_is_unsupported_not_china(self): r = run("clues.py", "lookup", "area-code", "020", "--country", "India", "--json") d = json.loads(r.stdout) self.assertEqual(d["status"], "unsupported") self.assertEqual(d["matches"], []) def test_country_without_that_table_is_unsupported(self): d = regions.lookup("plate", "ABC", "jp") self.assertEqual(d["status"], "unsupported") def test_foreign_plate_format_is_not_matched_against_cn(self): d = regions.lookup("plate", "KA01AB1234") self.assertEqual(d["matches"], []) def test_same_name_districts_stay_apart(self): rows = run("clues.py", "lookup", "admin", "朝阳区", "--json") chains = {tuple(m["chain"]) for m in json.loads(rows.stdout)["matches"]} self.assertIn(("北京市", "朝阳区"), chains) self.assertIn(("吉林省", "长春市", "朝阳区"), chains) class FixturePack(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.root = Path(self.tmp.name) write_pack(self.root / "packs", FIXTURE) self.env = {"GEO_SLEUTH_REGIONS": str(self.root / "packs")} def tearDown(self): self.tmp.cleanup() def lookup(self, *args): return json.loads(run("clues.py", "lookup", *args, "--json", env=self.env).stdout) def test_no_country_returns_every_country(self): d = self.lookup("area-code", "020") self.assertEqual(sorted(m["country_code"] for m in d["matches"]), ["CN", "QQ"]) self.assertIn("several countries", d["note"]) def test_country_restricts(self): d = self.lookup("area-code", "020", "--country", "Testland") self.assertEqual([m["admin2"] for m in d["matches"]], ["Porta"]) d = self.lookup("area-code", "020", "--country", "cn") self.assertEqual([m["admin2"] for m in d["matches"]], ["广州市"]) def test_removing_the_pack_leaves_cn_unchanged(self): with_pack = [m for m in self.lookup("area-code", "0817")["matches"] if m["country_code"] == "CN"] without = json.loads(run("clues.py", "lookup", "area-code", "0817", "--json").stdout)["matches"] self.assertEqual(with_pack, without) def test_multi_place_key_gives_one_candidate_each(self): board = self.root / "board.json" run("board.py", "--board", str(board), "init", "--photo", "x.jpg", env=self.env) r = run("board.py", "--board", str(board), "apply", "--kind", "area-code", "--value", "0120-555", "--country", "qq", env=self.env) self.assertEqual(r.returncode, 0, r.stderr) b = json.loads(board.read_text(encoding="utf-8")) self.assertIn("Alda", b["candidates"]) self.assertIn("Brin", b["candidates"]) self.assertEqual(b["candidates"]["Alda"]["cid"], "QQ/North/Alda") # one piece of evidence per place self.assertEqual(sum(1 for e in b["evidence"] if e["candidate"] == "North"), 1) def test_siblings_listed_by_children_are_down_weighted(self): # children records candidates with parent = the listed area; a lookup naming one of them must still count against the rest board = self.root / "board.json" run("board.py", "--board", str(board), "init", "--photo", "x.jpg", env=self.env) run("board.py", "--board", str(board), "add", "Porta", "Cora", "--level", "admin2", "--parent", "North", env=self.env) run("board.py", "--board", str(board), "apply", "--kind", "area-code", "--value", "020", "--country", "qq", env=self.env) b = json.loads(board.read_text(encoding="utf-8")) lr = {e["candidate"]: e["lr"] for e in b["evidence"]} self.assertGreater(lr["Porta"], 1) self.assertLess(lr["Cora"], 1) def test_same_name_elsewhere_gets_its_own_candidate(self): board = self.root / "board.json" run("board.py", "--board", str(board), "init", "--photo", "x.jpg", env=self.env) run("board.py", "--board", str(board), "add", "朝阳区", "--level", "district", "--parent", "北京市", env=self.env) r = run("board.py", "--board", str(board), "apply", "--kind", "area-code", "--value", "0431", "--country", "qq", env=self.env) self.assertEqual(r.returncode, 0, r.stderr) b = json.loads(board.read_text(encoding="utf-8")) self.assertIn("朝阳区 (长春市)", b["candidates"]) beijing = [e for e in b["evidence"] if e["candidate"] == "朝阳区"] self.assertTrue(all(e["lr"] < 1 for e in beijing), beijing) def test_board_country_drives_apply_and_next(self): board = self.root / "board.json" run("board.py", "--board", str(board), "init", "--photo", "x.jpg", "--country", "cn", env=self.env) run("board.py", "--board", str(board), "apply", "--kind", "area-code", "--value", "020", env=self.env) b = json.loads(board.read_text(encoding="utf-8")) self.assertNotIn("Porta", b["candidates"]) run("board.py", "--board", str(board), "add", "A区", "B区", "--level", "district", env=self.env) out = run("board.py", "--board", str(board), "next", env=self.env).stdout self.assertIn("baidu_pano.py sample", out) run("board.py", "--board", str(board), "country", "none", env=self.env) out = run("board.py", "--board", str(board), "next", env=self.env).stdout self.assertIn("gsv.py sheet", out) def board(self, *args): return run("board.py", "--board", str(self.root / "board.json"), *args, env=self.env) def read_board(self): return json.loads((self.root / "board.json").read_text(encoding="utf-8")) def test_country_without_the_table_is_not_down_weighted(self): # India has no pack here: an area code that isn't Chinese says nothing about India self.board("init", "--photo", "x.jpg") self.board("add", "China", "India", "Testland", "--level", "country") self.board("apply", "--kind", "area-code", "--value", "0817") lr = {} for e in self.read_board()["evidence"]: lr.setdefault(e["candidate"], []).append(e["lr"]) self.assertEqual(lr["China"], [20.0]) self.assertNotIn("India", lr) self.assertEqual(lr["Testland"], [0.05]) # its table was searched and had no 0817 def test_country_context_doesnt_stand_in_for_unknown_countries(self): for how in (("init", "--photo", "x.jpg", "--country", "cn"), ("init", "--photo", "x.jpg")): (self.root / "board.json").unlink(missing_ok=True) self.board(*how) self.board("add", "China", "India", "--level", "country") self.board("apply", "--kind", "area-code", "--value", "0817", "--country", "cn") cands = {e["candidate"] for e in self.read_board()["evidence"]} self.assertIn("China", cands) self.assertNotIn("India", cands, how) def test_province_and_same_stem_city_are_two_places(self): self.board("init", "--photo", "x.jpg") self.board("add", "吉林省", "--level", "admin1") self.board("add", "吉林市", "--level", "admin2", "--parent", "吉林省") self.board("apply", "--kind", "plate", "--value", "吉B", "--country", "cn") b = self.read_board() self.assertEqual(sorted(b["candidates"]), ["吉林市", "吉林省"]) self.assertEqual(b["candidates"]["吉林市"]["level"], "admin2") ev = {e["candidate"] for e in b["evidence"] if e["lr"] > 1} self.assertEqual(ev, {"吉林市", "吉林省"}) def test_country_context_doesnt_overwrite_global_observations(self): self.board("init", "--photo", "x.jpg", "--country", "us") self.board("add", "United States", "Japan", "--level", "country") self.board("apply", "--kind", "driving-side", "--value", "left") lr = {e["candidate"]: e["lr"] for e in self.read_board()["evidence"]} self.assertGreater(lr["Japan"], 1) self.assertLess(lr["United States"], 1) self.board("country", "cn") r = self.board("apply", "--kind", "driving-side", "--value", "right") self.assertNotIn("returned nothing", r.stdout) def test_broken_pack_doesnt_break_other_countries(self): bad = self.root / "packs" / "zz" bad.mkdir(parents=True) (bad / "region.json").write_text(json.dumps({"code": "ZZ", "name": "Brokenland", "names": ["Brokenland"], "status": "stub", "docs": [], "lookups": {"area-code": {"file": "data/missing.json"}}}), encoding="utf-8") d = self.lookup("area-code", "0817") self.assertEqual([m["country_code"] for m in d["matches"]], ["CN"]) self.assertIn("ZZ pack is broken", d["note"]) self.assertEqual(self.lookup("area-code", "0817", "--country", "zz")["status"], "error") # valid JSON of the wrong shape: same isolation, and lint reports it instead of crashing (bad / "data").mkdir() (bad / "data" / "missing.json").write_text("[]", encoding="utf-8") d = self.lookup("area-code", "0817") self.assertEqual([m["country_code"] for m in d["matches"]], ["CN"]) r = run("regions.py", "lint", "zz", env=self.env) self.assertEqual(r.returncode, 1) self.assertIn("must be an object", r.stdout) self.assertNotIn("Traceback", r.stderr) def test_lint_rejects_code_merged_names_and_missing_sources(self): bad = json.loads(json.dumps(FIXTURE)) bad["data/area_codes.json"]["_meta"].pop("source") bad["data/area_codes.json"]["entries"]["0120"] = [{"admin1": "North", "admin2": "Alda / Brin"}] bad["helper.py"] = "print('hi')\n" bad["clues.md"] = FIXTURE["clues.md"].replace("- Counterexamples: none seen\n", "") d = write_pack(self.root / "bad", bad) regions.packs(reload=True) errs, _ = regions.lint(dict(json.loads((d / "region.json").read_text(encoding="utf-8")), dir=d)) text = "\n".join(errs) self.assertIn("helper.py", text) self.assertIn("_meta.source", text) self.assertIn("Alda / Brin", text) self.assertIn("Counterexamples", text) class GlobalTables(unittest.TestCase): def test_driving_side_of_split_countries(self): import clues side = {c: clues.lookup_driving_side(None, c)["matches"][0]["side"] for c in ("China", "Hong Kong", "Macau", "United Kingdom", "Japan", "India", "United States", "Canada", "Ireland")} self.assertEqual(side, {"China": "right", "Hong Kong": "left", "Macau": "left", "United Kingdom": "left", "Japan": "left", "India": "left", "United States": "right", "Canada": "right", "Ireland": "left"}) class SameNamesAndPoi(unittest.TestCase): def test_same_name_under_another_parent_is_kept(self): import board b = {"candidates": {}} for parent, cid in (("南京市", "CN/江苏省/南京市/鼓楼区"), ("福州市", "CN/福建省/福州市/鼓楼区")): key = board._slot(b, "鼓楼区", parent, cid, "district") self.assertIsNotNone(key) b["candidates"][key] = {"parent": parent, "cid": cid, "level": "district"} self.assertEqual(sorted(b["candidates"]), ["鼓楼区", "鼓楼区 (福州市)"]) self.assertIsNone(board._slot(b, "鼓楼区", "福州市", "CN/福建省/福州市/鼓楼区", "district")) self.assertIsNone(board._slot(b, "鼓楼区", "南京市", None, "district")) def test_poi_region_without_pack_keeps_the_country_filter(self): import poi from unittest.mock import patch seen = {} def fake_osm(kw, city, n, proxy, country): seen["country"] = country return [] with patch.object(poi, "search_osm", fake_osm), patch.object(poi, "search_so", side_effect=AssertionError("360 used")), \ patch.object(sys, "argv", ["poi.py", "Connaught Place", "--region", "in"]): poi.main() self.assertEqual(seen["country"], "in") with patch.object(sys, "argv", ["poi.py", "x", "--region", "Narnia"]), self.assertRaises(SystemExit): poi.main() if __name__ == "__main__": unittest.main(verbosity=2) -
test_runtime.py 74 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = ["pillow", "pillow-heif", "numpy", "pyyaml"] # /// """Offline runtime regressions. Run: uv run skills/geo-sleuth/tests/test_runtime.py""" from __future__ import annotations import asyncio import contextlib from concurrent.futures import ThreadPoolExecutor, TimeoutError import http.server import io import json import math import os from pathlib import Path import re import shutil import subprocess import sys import tempfile import threading from types import SimpleNamespace import unittest from unittest.mock import AsyncMock, patch import numpy as np from PIL import Image, ImageDraw import yaml sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) import _net import _browser import _device import _image import match import pose import baidu_pano import doctor import evidence import gsv import osm import poi import revimg class NetworkTests(unittest.TestCase): def test_route_precedence(self): with patch.dict(os.environ, {"GEO_PROXY": "http://localhost:8123"}, clear=True): self.assertEqual(_net.resolve_proxy(), "http://localhost:8123") self.assertEqual(_net.resolve_proxy("http://localhost:8234"), "http://localhost:8234") self.assertIsNone(_net.resolve_proxy("direct")) self.assertIsNone(_net.resolve_proxy("")) with patch.dict(os.environ, {"HTTPS_PROXY": "http://localhost:9999"}, clear=True): self.assertIsNone(_net.resolve_proxy()) @unittest.skipUnless(shutil.which("curl"), "curl required") def test_curl_reaches_direct_and_explicit_proxy_despite_ambient_settings(self): paths = [] class Handler(http.server.BaseHTTPRequestHandler): def do_GET(self): paths.append(self.path) self.send_response(200) self.end_headers() self.wfile.write(b"ok") def log_message(self, *args): pass server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() base = f"http://127.0.0.1:{server.server_port}" try: with patch.dict(os.environ, {"HTTP_PROXY": "http://127.0.0.1:1", "http_proxy": "http://127.0.0.1:1", "ALL_PROXY": "http://127.0.0.1:1", "NO_PROXY": "*", "GEO_PROXY": "http://127.0.0.1:1"}): self.assertEqual(_net.fetch_bytes(base + "/direct", "direct"), b"ok") self.assertEqual(_net.fetch_bytes("http://example.invalid/proxied", base), b"ok") self.assertEqual(paths, ["/direct", "http://example.invalid/proxied"]) finally: server.shutdown() server.server_close() thread.join() def test_model_route_overrides_ambient_variables(self): with patch.dict(os.environ, {"GEO_PROXY": "http://chosen:8080", "https_proxy": "http://old:8080", "NO_PROXY": "*"}, clear=True): _net.model_proxy_env() self.assertEqual(os.environ["HTTPS_PROXY"], "http://chosen:8080") if os.name != "nt": # Windows environment variable names are case-insensitive self.assertNotIn("https_proxy", os.environ) self.assertNotIn("NO_PROXY", os.environ) _net.model_proxy_env("direct") self.assertNotIn("HTTPS_PROXY", os.environ) self.assertEqual(os.environ["NO_PROXY"], "*") def test_baidu_near_forwards_proxy(self): with patch.object(baidu_pano, "fetch_bytes", return_value=b'{"content":null}') as fetch: self.assertIsNone(baidu_pano.near(35, 110, proxy="http://chosen:8080")) self.assertEqual(fetch.call_args.args[1], "http://chosen:8080") def test_all_poi_sources_forward_proxy(self): with patch.object(poi, "_curl", return_value='{}') as fetch: poi.search_so("school", None, 2, "direct") self.assertEqual(fetch.call_args.kwargs["proxy"], "direct") poi.search_sug("school", "http://chosen:8080") self.assertEqual(fetch.call_args.kwargs["proxy"], "http://chosen:8080") with patch.object(poi, "_curl", return_value='[]') as fetch: poi.search_osm("school", None, 2, "http://chosen:8080", "us") self.assertEqual(fetch.call_args.kwargs["proxy"], "http://chosen:8080") def test_intake_forwards_route_to_both_engines(self): import intake from PIL import Image commands = [] def run(cmd, **kwargs): commands.append(cmd) return 0, "", "" with tempfile.TemporaryDirectory() as folder: root = Path(folder) photo = root / "photo.jpg" Image.new("RGB", (100, 100), "white").save(photo) # Preparation writes files used by intake; run the actual small local helpers. original = intake._run def local_or_search(cmd, **kwargs): if any(str(part).endswith("revimg.py") for part in cmd): return run(cmd, **kwargs) return original([sys.executable, *cmd[2:]], **kwargs) with patch.object(intake, "_run", side_effect=local_or_search), patch.object(sys, "argv", ["intake.py", str(photo), "--out-dir", str(root / "out"), "--no-ocr", "--max-variants", "1", "--proxy", "direct"]): intake.main() self.assertEqual(len(commands), 2) for cmd in commands: self.assertEqual(cmd[cmd.index("--proxy") + 1], "direct") self.assertEqual({cmd[cmd.index("--engines") + 1] for cmd in commands}, {"baidu", "yandex"}) class BrowserTests(unittest.IsolatedAsyncioTestCase): async def test_chromium_fallback_uses_same_route(self): browser = object() launch = AsyncMock(side_effect=[RuntimeError("Chrome absent"), browser]) p = SimpleNamespace(chromium=SimpleNamespace(launch=launch)) actual, label = await _browser.launch_browser(p, "socks5h://localhost:8123") self.assertIs(actual, browser) self.assertEqual(label, "Playwright Chromium") for call in launch.call_args_list: self.assertEqual(call.kwargs["proxy"]["server"], "socks5://localhost:8123") self.assertEqual(launch.call_args_list[0].kwargs["channel"], "chrome") self.assertNotIn("channel", launch.call_args_list[1].kwargs) async def test_explicit_direct_overrides_saved_browser_proxy(self): launch = AsyncMock(return_value=object()) p = SimpleNamespace(chromium=SimpleNamespace(launch=launch)) with patch.dict(os.environ, {"GEO_PROXY": "http://old:8080"}): await _browser.launch_browser(p, "direct") self.assertNotIn("proxy", launch.call_args.kwargs) self.assertIn("--no-proxy-server", launch.call_args.kwargs["args"]) async def test_both_browsers_missing_gives_install_fix(self): p = SimpleNamespace(chromium=SimpleNamespace(launch=AsyncMock(side_effect=RuntimeError("missing")))) with self.assertRaisesRegex(RuntimeError, "uvx playwright install chromium"): await _browser.launch_browser(p, "direct") async def test_reverse_search_all_engines_get_selected_route(self): browser = SimpleNamespace(new_context=AsyncMock(), close=AsyncMock()) manager = AsyncMock() manager.__aenter__.return_value = object() # Avoid requiring Playwright in this offline test's environment. fake = SimpleNamespace(async_playwright=lambda: manager) with tempfile.TemporaryDirectory() as folder, patch.dict(sys.modules, {"playwright.async_api": fake}), patch.object(revimg, "launch_browser", AsyncMock(return_value=(browser, "test"))) as launch, patch.object(revimg, "_text", AsyncMock(return_value={"links": []})), patch.object(revimg, "_baidu", AsyncMock(return_value={"links": []})) as baidu, patch.object(revimg, "_yandex", AsyncMock(return_value={"links": []})): Image.new("RGB", (8, 8)).save(Path(folder) / "photo.jpg") await revimg.run([Path(folder) / "photo.jpg"], ["baidu", "yandex"], Path(folder), "direct", ["school"], ["bing"]) self.assertEqual(launch.await_count, 3) self.assertTrue(all(call.args[1] == "direct" for call in launch.await_args_list)) self.assertEqual(baidu.await_args.args[-1], "direct") class DoctorTests(unittest.TestCase): def test_probe_distinguishes_transport_failure_and_service_block(self): for returncode, stdout, status in [(0, "200", "PASS"), (0, "429", "WARN"), (7, "000", "FAIL")]: with self.subTest(status=status), patch.object(doctor.subprocess, "run", return_value=SimpleNamespace(returncode=returncode, stdout=stdout)): row = doctor.probe(("service", "https://example.invalid"), "direct") self.assertEqual(row["status"], status) def test_local_checks_do_not_probe_network_or_print_proxy_credentials(self): with tempfile.TemporaryDirectory() as folder, patch.object(doctor, "browser_check", AsyncMock(return_value=doctor.check("browser", "PASS", "ready"))), patch.object(doctor, "probe") as probe, patch.dict(os.environ, {"GEO_PROXY": "http://user:secret@localhost:8080"}): report = doctor.diagnose(False, None) probe.assert_not_called() self.assertTrue(report["ok"]) self.assertNotIn("secret", json.dumps(report)) self.assertEqual(report["connection"], "configured proxy") QH = '[out:json][timeout:180];(area["name"="青海省"];area["name:zh"="青海省"];area["name:zh-Hans"="青海省"];)->.searchArea;nwr["power"="line"](area.searchArea);out geom tags;' LINE = {"type": "way", "id": 1, "geometry": [{"lat": 37.7, "lon": 95.3}, {"lat": 37.8, "lon": 95.4}], "tags": {"power": "line"}} def count(n): return {"elements": [{"type": "count", "tags": {"total": str(n)}}]} class OverpassMirrorTests(unittest.TestCase): """One mirror answered a whole-province --area query with a clean empty result while others had thousands of lines; it got cached.""" def run_with(self, replies, ql=QH): calls = [] def post(ep, q, proxy, timeout): calls.append(ep) return replies(ep, q) with tempfile.TemporaryDirectory() as d, patch.object(osm, "_post", side_effect=post), patch.object(osm.time, "sleep"): data = osm.run(ql, None, Path(d)) cached = list(Path(d).glob("*.json")) return data, calls, cached def test_empty_from_one_mirror_is_checked_on_another(self): data, calls, cached = self.run_with(lambda ep, q: ({"elements": []} if ep == osm.ENDPOINTS[0] else {"elements": [LINE]}, "")) self.assertEqual(len(data["elements"]), 1) self.assertEqual(calls, osm.ENDPOINTS[:2]) self.assertEqual(len(cached), 1) def test_empty_confirmed_by_two_mirrors_is_cached(self): data, calls, cached = self.run_with(lambda ep, q: ({"elements": []}, "")) self.assertEqual(data["elements"], []) self.assertEqual(len(calls), 2) self.assertEqual(len(cached), 1) def test_unconfirmed_empty_is_returned_but_not_cached(self): data, calls, cached = self.run_with(lambda ep, q: ({"elements": []}, "") if ep == osm.ENDPOINTS[0] else (None, "503")) self.assertEqual(data["elements"], []) self.assertEqual(cached, []) self.assertEqual(len(calls), len(osm.ENDPOINTS)) # one round, no retry sleeps def test_zero_area_count_needs_a_second_mirror(self): ql = '[out:json][timeout:120];(area["name"="青海省"];)->.a;.a out count;' data, _, _ = self.run_with(lambda ep, q: (count(0) if ep == osm.ENDPOINTS[0] else count(1), ""), ql) self.assertEqual(data["elements"][0]["tags"]["total"], "1") def test_bbox_empty_is_trusted(self): ql = '[out:json][timeout:180];nwr["power"="line"](37.6,95.2,37.8,95.4);out geom tags;' _, calls, cached = self.run_with(lambda ep, q: ({"elements": []}, ""), ql) self.assertEqual(len(calls), 1) self.assertEqual(len(cached), 1) def test_failure_names_every_mirror(self): replies = {osm.ENDPOINTS[0]: (None, "HTTP 406: Not Acceptable"), osm.ENDPOINTS[1]: (None, "HTTP 500: Internal Server Error"), osm.ENDPOINTS[2]: (None, "HTTP 504: server too busy")} with self.assertRaises(SystemExit) as cm: self.run_with(lambda ep, q: replies[ep]) for code in ("406", "500", "504"): self.assertIn(code, str(cm.exception)) def test_error_remark_not_cached(self): def replies(ep, q): if ep == osm.ENDPOINTS[0]: return {"elements": [], "remark": "runtime error: Query timed out"}, "" return {"elements": [LINE]}, "" data, _, cached = self.run_with(replies) self.assertEqual(len(data["elements"]), 1) self.assertEqual(len(cached), 1) class StreetViewTests(unittest.TestCase): def test_lookup_replies(self): ok = json.dumps([[0], [[1], [2, "Z4kP7jsHKunVOqfsg_khyw"], None, [None, None, [["168 W 60th Ave"], ["Vancouver"]]], None, [[None, [[None, None, 49.21598, -123.10927], None, [90.4]], None, [[]], None, None, None, None, []]], [None] * 7 + [[2024, 5]]]]) cases = { ok: "Z4kP7jsHKunVOqfsg_khyw", '[[5,"generic","Search returned no images."]]': None, } for reply, want in cases.items(): with patch.object(gsv, "_curl", return_value=reply.encode()): res = gsv.near(49.2161, -123.1093, 50, None) self.assertEqual(res and res["id"], want) ok_res = None with patch.object(gsv, "_curl", return_value=ok.encode()): ok_res = gsv.near(49.2161, -123.1093, 50, None) self.assertEqual(ok_res["date"], "2024-05") for bad in ('[[5,"generic","GeoPhotoService.SingleImageSearch is decommissioned and turned down."]]', '[3,"Invalid JSON payload received."]', "<html>blocked</html>", ""): with patch.object(gsv, "_curl", return_value=bad.encode()), self.assertRaises(gsv.ServiceError): gsv.near(49.2161, -123.1093, 50, None) def _coverage(panos: list[tuple[str, float, float]]) -> str: """A coverage tile reply in the layout photometa/ac/v1 returns; no panoramas gives the empty-tile layout.""" if not panos: return ")]}'\n" + json.dumps([None, None, [["ChA0YTkw", [None, None, [None, None, 40.0, -40.0]]]]]) rows = [[[[2, pid], None, [[None, None, la, lo], [19.2, None, 63.4], [45.9, 0.27, 1.5]]], [27, 113]] for pid, la, lo in panos] return ")]}'\n" + json.dumps([None, [None, rows]]) class CoverageTests(unittest.TestCase): PID = "77-Z-M-G0QlSNisjusa5DA" def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.cache = Path(self.tmp.name) def tearDown(self): self.tmp.cleanup() def world(self, panos): """_curl stand-in serving each z17 tile only the panoramas inside it; records requested tiles.""" asked = [] def curl(url, proxy, out=None, data=None): x, y = map(int, re.search(r"!1i(\d+)!2i(\d+)", url).groups()) asked.append((x, y)) inside = [(pid, la, lo) for pid, la, lo in panos if tuple(int(v // 256) for v in gsv.geo.ll2px(17, la, lo)) == (x, y)] return _coverage(inside).encode() return curl, asked def test_empty_tile_vs_failed_lookup(self): with patch.object(gsv, "_curl", return_value=_coverage([]).encode()): self.assertEqual(gsv.tile_panos(1, 1, None, self.cache), []) with patch.object(gsv, "_curl", return_value=_coverage([(self.PID, 51.44, 5.47)]).encode()): self.assertEqual(gsv.tile_panos(2, 2, None, self.cache), [(self.PID, 51.44, 5.47)]) for bad in ("<html>429 Too Many Requests</html>", "", ")]}'\n<html>429 Too Many Requests</html>", ")]}'\n[3,\"Invalid request\"]", ")]}'\n{\"error\": 1}"): with patch.object(gsv, "_curl", return_value=bad.encode()), self.assertRaises(gsv.ServiceError): gsv.tile_panos(3, 3, None, self.cache) def test_failed_reply_is_not_cached(self): bad = ")]}'\n<html>429 Too Many Requests</html>" with patch.object(gsv, "_curl", return_value=bad.encode()), self.assertRaises(gsv.ServiceError): gsv.tile_panos(5, 5, None, self.cache) self.assertEqual([f.name for f in self.cache.iterdir()], []) good = _coverage([(self.PID, 51.44, 5.47)]).encode() with patch.object(gsv, "_curl", return_value=good) as m: self.assertEqual(len(gsv.tile_panos(5, 5, None, self.cache)), 1) self.assertEqual(m.call_count, 1) # retried, not served from cache self.assertEqual(len(gsv.tile_panos(5, 5, None, self.cache)), 1) self.assertEqual(m.call_count, 1) # a valid reply is cached # an error reply left in the cache by an older version is fetched again (self.cache / "cov17_6_6.txt").write_text(bad, encoding="utf-8") with patch.object(gsv, "_curl", return_value=good) as m: self.assertEqual(len(gsv.tile_panos(6, 6, None, self.cache)), 1) self.assertEqual(m.call_count, 1) def test_disappearing_coverage_cache_is_refetched(self): cached = self.cache / "cov17_7_7.txt" cached.write_text(")]}'\n<html>429 Too Many Requests</html>", encoding="utf-8") read_text = Path.read_text def read(path, *args, **kwargs): if path == cached: path.unlink() # another worker removed a stale entry before this read return read_text(path, *args, **kwargs) good = _coverage([(self.PID, 51.44, 5.47)]).encode() with patch.object(Path, "read_text", read), patch.object(gsv, "_curl", return_value=good) as fetch: self.assertEqual(gsv.tile_panos(7, 7, None, self.cache), [(self.PID, 51.44, 5.47)]) self.assertEqual(fetch.call_count, 1) self.assertEqual(gsv._coverage_rows(cached.read_text(encoding="utf-8")), gsv._coverage_rows(good.decode())) def test_shared_coverage_tile_uses_one_fetch(self): for stale in (False, True): with self.subTest(stale=stale): cached = self.cache / "cov17_8_8.txt" if stale: cached.write_text(")]}'\n<html>429 Too Many Requests</html>", encoding="utf-8") fetching, release, started = threading.Event(), threading.Event(), threading.Event() good = _coverage([(self.PID, 51.44, 5.47)]).encode() def curl(*args, **kwargs): fetching.set() if not release.wait(5): raise RuntimeError("test did not release coverage request") return good def second(): started.set() return gsv.tile_panos(8, 8, None, self.cache) with patch.object(gsv, "_curl", side_effect=curl) as fetch, ThreadPoolExecutor(2) as ex: first = ex.submit(gsv.tile_panos, 8, 8, None, self.cache) try: self.assertTrue(fetching.wait(5)) other = ex.submit(second) self.assertTrue(started.wait(5)) with self.assertRaises(TimeoutError): other.result(timeout=0.1) finally: release.set() expected = [(self.PID, 51.44, 5.47)] self.assertEqual(first.result(timeout=5), expected) self.assertEqual(other.result(timeout=5), expected) self.assertEqual(fetch.call_count, 1) cached.unlink() def test_targets_reports_failed_lookups(self): pts = self.cache / "pts.json" pts.write_text(json.dumps({"church": [51.44, 5.47]}), encoding="utf-8") out = self.cache / "t.index.json" argv = ["gsv.py", "targets", "--points", str(pts), "--out", str(out), "--cache", str(self.cache / "c")] with patch.object(gsv, "_curl", return_value=b"<html>429 Too Many Requests</html>"), \ patch.object(sys, "argv", argv), patch("sys.stdout", new=io.StringIO()) as stdout, \ patch("sys.stderr", new=io.StringIO()), self.assertRaises(SystemExit) as e: gsv.main() self.assertIn("lookup failed", str(e.exception.code)) self.assertIn("0 targets without a panorama", stdout.getvalue()) self.assertIn("1 lookups failed", stdout.getvalue()) def _edge_target(self, lat): """A target 1 px inside the eastern edge of its z17 tile.""" x, y = gsv.geo.ll2px(17, lat, 5.0) tx = int(x // 256) tlat, tlon = gsv.geo.px2ll(17, (tx + 1) * 256 - 1, y) return tx, tlat, tlon def _east(self, lat, lon, metres): return lon + math.degrees(metres / (gsv.geo._R * math.cos(math.radians(lat)))) def test_radius_wider_than_one_tile(self): tx, lat, lon = self._edge_target(51.5) plon = self._east(lat, lon, 250) self.assertEqual(int(gsv.geo.ll2px(17, lat, plon)[0] // 256), tx + 2) # outside the old 3x3 block curl, asked = self.world([(self.PID, lat, plon)]) with patch.object(gsv, "_curl", side_effect=curl): got = gsv.panos_around(lat, lon, 12, 300, 2, 15, None, self.cache) self.assertEqual([g[0] for g in got], [self.PID]) self.assertAlmostEqual(got[0][3], 250, delta=1) self.assertIn(tx + 2, {a[0] for a in asked}) with patch.object(gsv, "_curl", side_effect=curl): # the exact distance filter still applies self.assertEqual(gsv.panos_around(lat, lon, 12, 200, 2, 15, None, self.cache), []) def test_high_latitude_default_radius(self): tx, lat, lon = self._edge_target(78.2) plon = self._east(lat, lon, 69) self.assertEqual(int(gsv.geo.ll2px(17, lat, plon)[0] // 256), tx + 2) curl, _ = self.world([(self.PID, lat, plon)]) with patch.object(gsv, "_curl", side_effect=curl): got = gsv.panos_around(lat, lon, 12, 70, 2, 15, None, self.cache) self.assertEqual([g[0] for g in got], [self.PID]) def test_tile_range_stays_small(self): self.assertLessEqual(len(gsv.tiles_around(51.5, 5.0, 70)), 4) # a 70 m radius spans at most 2x2 tiles of ~190 m tiles = gsv.tiles_around(0.0, 179.9999, 300) # wraps across the antimeridian self.assertTrue(all(0 <= x < 2 ** 17 for x, _ in tiles)) self.assertIn(0, {x for x, _ in tiles}) class FontTests(unittest.TestCase): def _tried_paths(self, env): tried = [] def fake_truetype(path, size): tried.append(path) raise OSError("not found") with patch.dict(os.environ, env, clear=True), patch.object(baidu_pano.ImageFont, "truetype", side_effect=fake_truetype), patch.object(baidu_pano.ImageFont, "load_default", return_value="default"): self.assertEqual(baidu_pano._font(16), "default") return tried def test_windows_cjk_fonts_are_tried(self): tried = self._tried_paths({}) self.assertIn("C:/Windows/Fonts/msyh.ttc", tried) self.assertIn("C:/Windows/Fonts/msyhbd.ttc", tried) self.assertIn("C:/Windows/Fonts/simsun.ttc", tried) def test_geo_font_is_tried_first(self): tried = self._tried_paths({"GEO_FONT": "D:/fonts/custom.ttf"}) self.assertEqual(tried[0], "D:/fonts/custom.ttf") def test_first_available_font_is_used(self): def fake_truetype(path, size): if path != "C:/Windows/Fonts/simhei.ttf": raise OSError("not found") return path with patch.dict(os.environ, {}, clear=True), patch.object(baidu_pano.ImageFont, "truetype", side_effect=fake_truetype): self.assertEqual(baidu_pano._font(16), "C:/Windows/Fonts/simhei.ttf") class SkillMetadataTests(unittest.TestCase): def test_frontmatter_meets_agent_skills_spec(self): root = Path(__file__).resolve().parents[1] m = re.match(r"---\n(.*?)\n---\n", (root / "SKILL.md").read_text(encoding="utf-8"), re.S) self.assertIsNotNone(m, "frontmatter must open and close with --- lines") meta = yaml.safe_load(m.group(1)) # strict parser: a ': ' inside the description breaks it self.assertIsInstance(meta, dict) self.assertEqual(meta["name"], root.name) self.assertIsInstance(meta["description"], str) self.assertTrue(meta["description"].strip()) self.assertLessEqual(len(meta["description"]), 1024) # https://agentskills.io/specification class EvidenceSpecTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.base = Path(self.tmp.name) (self.base / "area.jpg").write_bytes(b"jpg") (self.base / "area.json").write_text("{}", encoding="utf-8") def tearDown(self): self.tmp.cleanup() def test_missing_map_is_named_instead_of_key_error(self): with self.assertRaises(SystemExit) as e: evidence.build({"camera": [1, 2]}, self.base, self.base / "out.jpg") self.assertIn("missing 'map'", str(e.exception.code)) def test_map_without_georeference(self): (self.base / "area.json").unlink() self.assertIn("no georeference", evidence.check_spec({"map": "area.jpg"}, self.base)[0]) def test_item_keys_and_panel_images(self): errs = evidence.check_spec({"map": "area.jpg", "labels": [{"at": [1, 2]}], "lines": [{"from": [1, 2], "bearing": 3}], "panels": [{"image": "sv1.jpg"}]}, self.base) self.assertEqual(len(errs), 3) self.assertIn("labels[0] is missing 'text'", errs[0]) self.assertIn("lines[0] is missing 'length_m'", errs[1]) self.assertIn("panels[0] image not found", errs[2]) def test_valid_spec(self): self.assertEqual(evidence.check_spec({"map": "area.jpg", "labels": [{"at": [1, 2], "text": "x"}]}, self.base), []) def test_invalid_path_and_item_types_are_named(self): cases = [ ([], "JSON object"), *[({"map": value}, "'map'") for value in (None, 7, "")], *[({"map": "area.jpg", key: value}, repr(key)) for key, value in (("labels", None), ("labels", {}), ("lines", "bad"), ("panels", None))], *[({"map": "area.jpg", key: [value]}, f"{key}[0]") for key, value in (("labels", None), ("labels", "at text"), ("lines", 3))], ({"map": "area.jpg", "panels": [{"image": None}]}, "panels[0]"), ] for spec, expected in cases: with self.subTest(spec=spec): self.assertTrue(any(expected in error for error in evidence.check_spec(spec, self.base))) def test_missing_files_are_reported_together(self): errors = evidence.check_spec({"map": "missing.jpg", "panels": [{"image": "missing-panel.jpg"}]}, self.base) self.assertEqual(len(errors), 3) self.assertIn("map not found", errors[0]) self.assertIn("no georeference", errors[1]) self.assertIn("panels[0] image not found", errors[2]) def test_invalid_spec_cli_exits_without_traceback(self): spec_path = self.base / "bad.json" spec_path.write_text(json.dumps({"map": None, "labels": None, "panels": [{"image": None}]}), encoding="utf-8") out = self.base / "out.jpg" result = subprocess.run([sys.executable, str(Path(evidence.__file__)), str(spec_path), "--out", str(out)], capture_output=True, text=True, encoding="utf-8") self.assertEqual(result.returncode, 1) for expected in ("'map'", "'labels'", "panels[0]", "evidence.py --help"): self.assertIn(expected, result.stderr) self.assertNotIn("Traceback", result.stderr) self.assertFalse(out.exists()) class DeviceTests(unittest.TestCase): @staticmethod def _torch(cuda=False, mps=False): return SimpleNamespace(__version__="2.0.0+test", cuda=SimpleNamespace(is_available=lambda: cuda), backends=SimpleNamespace(mps=SimpleNamespace(is_available=lambda: mps))) def _pick(self, value=None, **have): env = {} if value is None else {"GEO_DEVICE": value} with patch.dict(os.environ, env, clear=True): return _device.torch_device(self._torch(**have)) def _exit_message(self, value, **have): with self.assertRaises(SystemExit) as caught: self._pick(value, **have) message = str(caught.exception.code) self.assertEqual(len(message.splitlines()), 1) self.assertIn("torch 2.0.0+test", message) return message def test_auto_selection_order(self): self.assertEqual(self._pick(cuda=True, mps=True), "cuda") self.assertEqual(self._pick(cuda=True), "cuda") self.assertEqual(self._pick(mps=True), "mps") self.assertEqual(self._pick(), "cpu") self.assertEqual(self._pick("", cuda=True), "cuda") def test_explicit_override(self): self.assertEqual(self._pick("cpu", cuda=True, mps=True), "cpu") self.assertEqual(self._pick("cuda", cuda=True, mps=True), "cuda") self.assertEqual(self._pick("mps", cuda=True, mps=True), "mps") self.assertEqual(self._pick(" CUDA ", cuda=True), "cuda") def test_invalid_value_exits_with_one_line(self): for value in ("gpu", "cuda:0", "auto"): message = self._exit_message(value, cuda=True) self.assertIn(repr(value), message) self.assertIn("cpu, cuda, mps", message) def test_unavailable_device_exits_instead_of_falling_back(self): message = self._exit_message("cuda", mps=True) self.assertIn("GEO_DEVICE=cuda", message) self.assertIn("cuda=False, mps=True", message) message = self._exit_message("mps", cuda=True) self.assertIn("GEO_DEVICE=mps", message) self.assertIn("cuda=True, mps=False", message) def test_both_model_scripts_use_the_shared_helper(self): scripts = Path(_device.__file__).parent for name in ("match.py", "sat_scan.py"): text = (scripts / name).read_text(encoding="utf-8") self.assertIn("self.dev = torch_device(torch)", text) self.assertNotIn("mps.is_available", text) HEIC_HEAD = b"\x00\x00\x00\x1cftypheic" + b"\x00" * 64 class ImageLoadingTests(unittest.TestCase): """scripts/_image.py: explicit formats, content sniffing, RAW rejection, failure categories, folder discovery.""" def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.d = Path(self.tmp.name) self.addCleanup(self.tmp.cleanup) def _save(self, name, fmt="JPEG", color=(200, 30, 30), size=(32, 24)): Image.new("RGB", size, color).save(self.d / name, format=fmt) return self.d / name def _category(self, path): with self.assertRaises(_image.ImageReadError) as caught: _image.open_image(path) self.assertEqual(len(caught.exception.reason.splitlines()), 1) return caught.exception.category, caught.exception.reason def test_sniff_reads_content_not_suffix(self): heads = { "JPEG": b"\xff\xd8\xff\xe0", "PNG": b"\x89PNG\r\n\x1a\n", "WEBP": b"RIFF\x00\x00\x00\x00WEBPVP8 ", "GIF": b"GIF89a", "HEIF": HEIC_HEAD[:16], "AVIF": b"\x00\x00\x00\x1cftypavif", "TIFF": b"II*\x00\x08\x00\x00\x00", "TEXT": b'{"status":400}', } for kind, head in heads.items(): self.assertEqual(_image.sniff(head, ".jpg"), kind) self.assertEqual(_image.sniff(b"II*\x00\x10\x00\x00\x00CR\x02\x00", ".jpg"), "RAW") # Canon CR2 self.assertEqual(_image.sniff(b"\x00\x00\x00\x18ftypcrx ", ".cr3"), "RAW") # Canon CR3 self.assertEqual(_image.sniff(b"MM\x00*\x00\x00\x00\x08", ".NEF"), "RAW") # TIFF-based RAW by suffix self.assertEqual(_image.sniff(b"FUJIFILMCCD-RAW 0201", ".raf"), "RAW") self.assertIsNone(_image.sniff(b"\x00\x01\x02\x03", ".jpg")) def test_supported_formats_open_whatever_the_suffix(self): for name, fmt in (("a.jpg", "JPEG"), ("b.png", "PNG"), ("c.webp", "WEBP"), ("d.jpg", "PNG"), ("e", "JPEG"), ("f.gif", "GIF")): with _image.open_image(self._save(name, fmt)) as im: self.assertEqual((im.format, im.size), (fmt, (32, 24))) @unittest.skipUnless(_image.HEIF, "pillow-heif not installed") def test_heic_decodes_and_keeps_metadata(self): ex = Image.Exif() ex[0x8769] = {0xA405: 26} # FocalLengthIn35mmFilm Image.new("RGB", (40, 30), (0, 0, 255)).save(self.d / "phone.heic", format="HEIF", exif=ex) im = _image.open_image(self.d / "phone.heic") self.assertEqual((im.format, im.size), ("HEIF", (40, 30))) self.assertGreater(im.getpixel((0, 0))[2], 200) self.assertEqual(im.getexif().get_ifd(0x8769).get(0xA405), 26) def test_failure_categories(self): (self.d / "empty.jpg").write_bytes(b"") (self.d / "error.jpg").write_bytes(b'{"status":400,"msg":"bad"}') (self.d / "noise.jpg").write_bytes(b"\x00\x01\x02\x03" * 20) (self.d / "IMG_1.CR2").write_bytes(b"II*\x00\x10\x00\x00\x00CR\x02\x00" + b"\x00" * 64) (self.d / "IMG_2.dng").write_bytes(b"II*\x00\x08\x00\x00\x00" + b"\x00" * 64) full = self._save("full.jpg").read_bytes() (self.d / "cut.jpg").write_bytes(full[: len(full) // 2]) Image.new("RGB", (8, 8)).save(self.d / "frame.ppm", format="PPM") expect = {"empty.jpg": "empty", "error.jpg": "not-image", "noise.jpg": "not-image", "IMG_1.CR2": "raw", "IMG_2.dng": "raw", "cut.jpg": "decode-error", "frame.ppm": "unsupported"} for name, category in expect.items(): got, reason = self._category(self.d / name) self.assertEqual(got, category, f"{name}: {reason}") self.assertIn("export it as JPEG or PNG", self._category(self.d / "IMG_1.CR2")[1]) def test_tiff_based_raw_is_rejected_by_content(self): """Structure of an iPhone ProRAW DNG: the first image is a full-size 8-bit preview marked reduced-resolution, the real 10-bit linear raw sits in a SubIFD. Pillow would read the preview, whatever the suffix.""" from PIL import TiffImagePlugin for name, tags in (("dng_named.jpg", {254: 1, 50706: b"\x01\x06\x00\x00"}), ("preview_only", {254: 1, 330: 100})): info = TiffImagePlugin.ImageFileDirectory_v2() for k, v in tags.items(): info[k] = v if 50706 in tags: info.tagtype[50706] = 1 Image.new("RGB", (40, 30), (90, 120, 150)).save(self.d / name, format="TIFF", tiffinfo=info) category, reason = self._category(self.d / name) self.assertEqual(category, "raw", name) self.assertIn("embedded preview", reason) self._save("plain.tif", "TIFF") with _image.open_image(self.d / "plain.tif") as im: self.assertEqual(im.format, "TIFF") def test_heif_failure_keeps_its_cause(self): (self.d / "x.jpg").write_bytes(HEIC_HEAD) with patch.object(_image, "HEIF", False): self.assertEqual(self._category(self.d / "x.jpg")[0], "missing-decoder") if _image.HEIF: category, reason = self._category(self.d / "x.jpg") self.assertEqual(category, "decode-error") self.assertIn("HEIF that could not be decoded (", reason) # the decoder's own message, not a guess def test_find_images_keeps_old_order_then_adds_new_formats(self): for name, fmt in (("b.jpg", "JPEG"), ("a.jpeg", "JPEG"), ("c.png", "PNG"), ("d.webp", "WEBP"), ("e.HEIC", "PNG"), ("f.heif", "PNG"), ("g.bin", "PNG"), ("h", "JPEG")): self._save(name, fmt) (self.d / "notes.json").write_text("{}", encoding="utf-8") (self.d / "readme.txt").write_text("hello", encoding="utf-8") (self.d / "IMG_3.CR2").write_bytes(b"II*\x00\x10\x00\x00\x00CR\x02\x00") names = [f.name for f in _image.find_images(self.d)] old = sorted(["b.jpg", "a.jpeg"]) + ["c.png"] self.assertEqual(names[:3], old) self.assertEqual(sorted(names[3:]), sorted(["d.webp", "e.HEIC", "f.heif", "IMG_3.CR2", "g.bin", "h"])) self.assertNotIn("notes.json", names) self.assertNotIn("readme.txt", names) def test_open_or_exit_is_one_line(self): (self.d / "IMG_4.NEF").write_bytes(b"MM\x00*\x00\x00\x00\x08" + b"\x00" * 64) with self.assertRaises(SystemExit) as caught: _image.open_or_exit(self.d / "IMG_4.NEF", "photo") message = str(caught.exception.code) self.assertEqual(len(message.splitlines()), 1) self.assertIn("cannot read photo", message) self.assertIn("export it as JPEG or PNG", message) def test_photo_scripts_exit_cleanly_on_raw_and_read_heic(self): script = Path(_image.__file__).with_name("imgprep.py") raw = self.d / "IMG_5.ARW" raw.write_bytes(b"II*\x00\x08\x00\x00\x00" + b"\x00" * 64) r = subprocess.run([sys.executable, str(script), "zoom", str(raw), "--box", "0,0,4,4", "--out", str(self.d / "z.png")], capture_output=True, text=True, encoding="utf-8") self.assertNotEqual(r.returncode, 0) self.assertNotIn("Traceback", r.stderr) self.assertIn("export it as JPEG or PNG", r.stderr) if _image.HEIF: Image.new("RGB", (40, 30), (0, 200, 0)).save(self.d / "p.heic", format="HEIF") r = subprocess.run([sys.executable, str(script), "zoom", str(self.d / "p.heic"), "--box", "0,0,20,20", "--out", str(self.d / "z.png")], capture_output=True, text=True, encoding="utf-8") self.assertEqual(r.returncode, 0, r.stderr) self.assertTrue((self.d / "z.png").exists()) class MatchImageLoadingTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.d = Path(self.tmp.name) self.addCleanup(self.tmp.cleanup) def _save(self, name, fmt="JPEG", color=(200, 30, 30)): Image.new("RGB", (32, 24), color).save(self.d / name, format=fmt) def _load(self, files): err = io.StringIO() with contextlib.redirect_stderr(err): ok, ims = match._open_images(files) return ok, ims, err.getvalue() def test_unreadable_files_are_skipped_with_reasons(self): self._save("a.jpg") self._save("b.jpg", color=(30, 200, 30)) (self.d / "heic.jpg").write_bytes(HEIC_HEAD) (self.d / "error.jpg").write_bytes('{"status":400,"msg":"bad"}'.encode()) (self.d / "empty.jpg").write_bytes(b"") full = (self.d / "a.jpg").read_bytes() (self.d / "cut.jpg").write_bytes(full[: len(full) // 2]) ok, ims, err = self._load(sorted(self.d.glob("*.jpg"))) self.assertEqual([f.name for f in ok], ["a.jpg", "b.jpg"]) self.assertEqual([im.size for im in ims], [(32, 24), (32, 24)]) self.assertEqual(len(err.strip().splitlines()), 1) self.assertIn("skipped 4 of 6 files", err) self.assertIn("text, not an image", err) self.assertIn("1 empty file", err) def test_missing_decoder_is_reported_as_such(self): self._save("a.jpg") (self.d / "h.heic").write_bytes(HEIC_HEAD) with patch.object(_image, "HEIF", False): ok, _, err = self._load([self.d / "a.jpg", self.d / "h.heic"]) self.assertEqual([f.name for f in ok], ["a.jpg"]) self.assertIn("needs pillow-heif", err) def test_all_unreadable_exits_with_one_line(self): (self.d / "x.jpg").write_bytes(b"<html>blocked</html>") (self.d / "y.CR2").write_bytes(b"II*\x00\x10\x00\x00\x00CR\x02\x00") with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as caught: match._open_images([self.d / "x.jpg", self.d / "y.CR2"]) self.assertEqual(len(str(caught.exception.code).splitlines()), 1) self.assertIn("none of the 2 candidate files", str(caught.exception.code)) def _candidates(self): args = SimpleNamespace(images=str(self.d), items=None, panos=None, cache=str(self.d / ".cache")) err = io.StringIO() with contextlib.redirect_stderr(err): items, ims = match._load_candidates(args) return items, ims, err.getvalue() def test_mixed_folder_keeps_ids_aligned(self): self._save("first.jpg", color=(255, 0, 0)) (self.d / "middle.jpg").write_bytes(HEIC_HEAD) self._save("last.png", "PNG", color=(0, 0, 255)) self._save("UPPER.WEBP", "WEBP", color=(0, 255, 0)) self._save("renamed.bin", "PNG", color=(255, 255, 0)) items, ims, _ = self._candidates() ids = [it["id"] for it in items] self.assertEqual(ids[:2], ["first", "last"]) self.assertEqual(sorted(ids[2:]), ["UPPER", "renamed"]) colors = {it["id"]: im.convert("RGB").getpixel((0, 0)) for it, im in zip(items, ims)} self.assertGreater(colors["first"][0], 240) self.assertGreater(colors["last"][2], 240) self.assertGreater(colors["UPPER"][1], 240) @unittest.skipUnless(_image.HEIF, "pillow-heif not installed") def test_heic_only_folder(self): for i, c in enumerate(((255, 0, 0), (0, 0, 255))): Image.new("RGB", (40, 30), c).save(self.d / f"p{i}.HEIC", format="HEIF") Image.new("RGB", (40, 30), (0, 200, 0)).save(self.d / "phone.jpg", format="HEIF") items, ims, err = self._candidates() self.assertEqual([it["id"] for it in items], ["phone", "p0", "p1"]) self.assertEqual([im.size for im in ims], [(40, 30)] * 3) self.assertEqual(err, "") # EXIF orientation -> the transpose a viewer applies to the stored pixels; storing = applying the inverse VIEW = {2: Image.Transpose.FLIP_LEFT_RIGHT, 3: Image.Transpose.ROTATE_180, 4: Image.Transpose.FLIP_TOP_BOTTOM, 5: Image.Transpose.TRANSPOSE, 6: Image.Transpose.ROTATE_270, 7: Image.Transpose.TRANSVERSE, 8: Image.Transpose.ROTATE_90} INVERSE = {Image.Transpose.ROTATE_270: Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_90: Image.Transpose.ROTATE_270} def upright_control(w=60, h=40): """Asymmetric: every flip or rotation of it is a different image.""" im = Image.new("RGB", (w, h), (230, 230, 230)) im.paste((220, 30, 30), (0, 0, w // 3, h // 2)) # red, top-left im.paste((30, 30, 220), (w - w // 4, h // 2, w, h)) # blue, bottom-right im.paste((30, 160, 30), (w // 2, 0, w // 2 + 4, h // 4)) # green tick near the top return im def stored_as(control, o): if o == 1: return control.copy() t = VIEW[o] return control.transpose(INVERSE.get(t, t)) def save_oriented(control, o, path, fmt="PNG", **kw): ex = Image.Exif() ex[0x0112] = o stored_as(control, o).save(path, format=fmt, exif=ex, **kw) class OrientationTests(unittest.TestCase): """Stage 2 of #29: pixel coordinates refer to the upright frame; EXIF orientation is applied exactly once.""" def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.d = Path(self.tmp.name) self.addCleanup(self.tmp.cleanup) self.control = upright_control() def test_all_eight_orientations_match_the_upright_control(self): for o in range(1, 9): save_oriented(self.control, o, self.d / f"o{o}.png") im = _image.open_photo(self.d / f"o{o}.png") self.assertEqual(im.size, self.control.size, f"orientation {o}") self.assertEqual(im.convert("RGB").tobytes(), self.control.tobytes(), f"orientation {o}") self.assertEqual(_image.upright(im).tobytes(), im.tobytes(), f"orientation {o} applied twice") def test_jpeg_orientations_match_within_compression_noise(self): ref = np.asarray(self.control, dtype=np.int16) for o in (3, 6, 8): save_oriented(self.control, o, self.d / f"o{o}.jpg", fmt="JPEG", quality=95) got = np.asarray(_image.open_photo(self.d / f"o{o}.jpg").convert("RGB"), dtype=np.int16) self.assertEqual(got.shape, ref.shape) self.assertLess(np.abs(got - ref).mean(), 3, f"orientation {o}") def test_normal_photo_is_returned_untouched(self): save_oriented(self.control, 1, self.d / "o1.png") im = _image.open_image(self.d / "o1.png") self.assertIs(_image.upright(im), im) @unittest.skipUnless(_image.HEIF, "pillow-heif not installed") def test_heif_is_not_rotated_again(self): """libheif applies irot/imir itself and pillow-heif resets the EXIF tag; the HEIF spec says EXIF orientation is ignored. Whatever the EXIF says, the result must be exactly what the HEIF decoder produced.""" for o in (1, 3, 6, 8): save_oriented(self.control, o, self.d / f"h{o}.heic", fmt="HEIF", quality=-1) decoded = Image.open(self.d / f"h{o}.heic") decoded.load() im = _image.open_photo(self.d / f"h{o}.heic") self.assertEqual(_image.orientation(im), 1) self.assertEqual(im.size, decoded.size) self.assertEqual(im.tobytes(), decoded.tobytes(), f"orientation {o}") def test_frames_are_read_from_the_record_not_guessed_from_the_size(self): self.assertEqual(_image.frame_of({}), "stored") # written before orientation was applied self.assertEqual(_image.frame_of({"frame": "upright"}), "upright") self.assertEqual(_image.frame_of({}, "upright"), "upright") # callers can explicitly override the default with self.assertRaises(SystemExit): _image.frame_of({"frame": "sideways"}) for control in (self.control, upright_control(40, 40)): # square: no size tells the frames apart for o in range(1, 9): save_oriented(control, o, self.d / f"o{o}.png") err = io.StringIO() with contextlib.redirect_stderr(err): stored = _image.open_photo(self.d / f"o{o}.png", frame="stored", size=stored_as(control, o).size) self.assertEqual(stored.convert("RGB").tobytes(), stored_as(control, o).tobytes(), f"orientation {o}") self.assertEqual("stored frame" in err.getvalue(), o != 1, err.getvalue()) self.assertNotIn("warning", err.getvalue()) up = _image.open_photo(self.d / f"o{o}.png", size=control.size) self.assertEqual(up.convert("RGB").tobytes(), control.tobytes(), f"orientation {o}") def test_a_size_that_fits_neither_frame_is_flagged(self): save_oriented(self.control, 6, self.d / "o6.png") # 40x60 stored, 60x40 upright err = io.StringIO() with contextlib.redirect_stderr(err): _image.open_photo(self.d / "o6.png", size=[40, 60]) # a stored-frame size in an upright record self.assertIn('set "frame": "stored"', err.getvalue()) err = io.StringIO() with contextlib.redirect_stderr(err): _image.open_photo(self.d / "o6.png", size=[120, 80]) # same aspect, scaled: fine self.assertEqual(err.getvalue(), "") def test_match_query_and_candidates_use_the_upright_frame(self): save_oriented(self.control, 6, self.d / "q.png") save_oriented(self.control, 8, self.d / "c8.png") self.control.save(self.d / "c1.png") self.assertEqual(match.open_photo(self.d / "q.png", "query").convert("RGB").tobytes(), self.control.tobytes()) args = SimpleNamespace(images=str(self.d), items=None, panos=None, cache=str(self.d / ".cache")) with contextlib.redirect_stderr(io.StringIO()): items, ims = match._load_candidates(args) for it, im in zip(items, ims): self.assertEqual(im.convert("RGB").tobytes(), self.control.tobytes(), it["id"]) def _run(self, script, *args): r = subprocess.run([sys.executable, str(Path(_image.__file__).with_name(script)), *map(str, args)], capture_output=True, text=True, encoding="utf-8") self.assertEqual(r.returncode, 0, r.stderr) return r def test_imgprep_crops_use_the_upright_frame(self): save_oriented(self.control, 6, self.d / "o6.png") self.control.save(self.d / "up.png") for name in ("o6", "up"): self._run("imgprep.py", "zoom", self.d / f"{name}.png", "--box", "0,0,30,20", "--scale", "2", "--out", self.d / f"z_{name}.png") self.assertEqual(Image.open(self.d / "z_o6.png").tobytes(), Image.open(self.d / "z_up.png").tobytes()) def _pose_case(self, w, h): lat0, lon0 = 30.0, 120.0 rec = {"_frame": {"lat0": lat0, "lon0": lon0, "image_size": [w, h]}, "_params": [0.0, 0.0, 10.0, 0.0, 0.0, 0.0, 200.0]} # east, north, height, heading, pitch, roll, focal_px pts = [{"name": "A", "ll": [lat0 + 0.0009, lon0 + 0.0002], "h": 5.0, "px": [w * 5 // 8, h * 9 // 16]}, {"name": "B", "ll": [lat0 + 0.0009, lon0 - 0.0003], "h": 0.0, "px": [w // 4, h * 5 // 8]}] return rec, pts def _draw(self, photo, rec, pts, out): with contextlib.redirect_stderr(io.StringIO()) as err: pose.draw(photo, rec, pts, out, observed=True) with Image.open(out) as im: return im.tobytes(), err.getvalue() def test_pose_overlay_uses_the_upright_frame(self): for w, h in ((240, 160), (160, 160)): scene = upright_control(w, h) scene.save(self.d / "up.png") rec, pts = self._pose_case(w, h) rec["_frame"]["frame"] = "upright" control, _ = self._draw(self.d / "up.png", rec, pts, self.d / "d_up.png") for o in range(2, 9): save_oriented(scene, o, self.d / f"o{o}.png") got, err = self._draw(self.d / f"o{o}.png", rec, pts, self.d / f"d_o{o}.png") self.assertEqual(got, control, f"{w}x{h} orientation {o}") self.assertEqual(err, "") def test_old_pose_records_draw_as_they_did(self): """A pose.json from before #29 has no frame: its points were drawn on the stored pixels, and still are. Covers the cases a size check can't tell apart: mirror / 180° (2, 3, 4) keep the size, and a square photo keeps it under 6.""" for (w, h), orients in (((240, 160), (2, 3, 4, 6, 8)), ((160, 160), (6, 3))): scene = upright_control(w, h) for o in orients: stored = stored_as(scene, o) stored.save(self.d / "plain.png") # what the old pose.draw opened: no EXIF to apply save_oriented(scene, o, self.d / f"o{o}.png") rec, pts = self._pose_case(*stored.size) # recorded on the stored pixels old, _ = self._draw(self.d / "plain.png", rec, pts, self.d / "d_plain.png") got, err = self._draw(self.d / f"o{o}.png", rec, pts, self.d / f"d_o{o}.png") self.assertEqual(got, old, f"{w}x{h} orientation {o}") self.assertIn(f"EXIF orientation {o}", err) self.assertNotIn("warning", err) def _pose_spec(self, W=400, H=300): lat0, lon0 = 30.0, 120.0 kx, ky = pose._frame(lat0) truth = np.array([3.0, -4.0, 12.0, 20.0, -2.0, 0.0, 350.0]) enu = np.array([[-60, 220, 0], [40, 260, 25], [90, 180, 5], [-20, 140, 15], [10, 320, 40], [-90, 300, 8]], float) uv, _ = pose.project(truth, enu, W, H) return {"image_size": [W, H], "init": {"at": [lat0, lon0], "height": 10, "heading": 15, "pitch": 0, "hfov": 60}, "points": [{"name": f"P{i}", "ll": [lat0 + n / ky, lon0 + e / kx], "h": u, "px": [float(x), float(y)]} for i, ((e, n, u), (x, y)) in enumerate(zip(enu, uv))]} def test_pose_solve_records_the_frame_of_its_points(self): spec = self._pose_spec() with contextlib.redirect_stdout(io.StringIO()): self.assertEqual(pose.solve(spec, 50, 2)["_frame"]["frame"], "stored") self.assertEqual(pose.solve(dict(spec, frame="stored"), 50, 2)["_frame"]["frame"], "stored") self.assertEqual(pose.solve(dict(spec, frame="upright"), 50, 2)["_frame"]["frame"], "upright") def _solve_photo(self, spec, photo, name): source, saved, overlay = (self.d / f"{name}{suffix}" for suffix in ("_spec.json", "_pose.json", ".png")) source.write_text(json.dumps(spec), encoding="utf-8") self._run("pose.py", "solve", source, "--photo", photo, "--out", overlay, "--save", saved, "--search-radius", 50, "--restarts", 2) with Image.open(overlay) as im: pixels = im.tobytes() return json.loads(saved.read_text(encoding="utf-8")), pixels def test_legacy_pose_specs_solve_in_the_stored_frame(self): """Re-running an old input spec must preserve its overlay, not just reading an old solved pose.json.""" for w, h, o in ((400, 300, 3), (400, 300, 6), (300, 300, 6)): with self.subTest(size=(w, h), orientation=o): scene = upright_control(w, h) stored = stored_as(scene, o) stored.save(self.d / "plain.png") save_oriented(scene, o, self.d / "oriented.png") spec = self._pose_spec(*stored.size) # legacy input: no frame key before, control = self._solve_photo(spec, self.d / "plain.png", "legacy_control") after, actual = self._solve_photo(spec, self.d / "oriented.png", "legacy_oriented") self.assertEqual(after["_frame"]["frame"], "stored") self.assertEqual(after["_params"], before["_params"]) self.assertEqual(actual, control) def test_new_pose_specs_solve_in_the_upright_frame(self): scene = upright_control(400, 300) scene.save(self.d / "upright.png") spec = dict(self._pose_spec(), frame="upright") before, control = self._solve_photo(spec, self.d / "upright.png", "upright_control") for o in (3, 6): with self.subTest(orientation=o): save_oriented(scene, o, self.d / "oriented.png") after, actual = self._solve_photo(spec, self.d / "oriented.png", "upright_oriented") self.assertEqual(after["_frame"]["frame"], "upright") self.assertEqual(after["_params"], before["_params"]) self.assertEqual(actual, control) def test_terrain_ridge_points_and_overlay_use_the_upright_frame(self): w, h = 240, 160 scene = Image.new("RGB", (w, h), (210, 220, 235)) # sky ImageDraw.Draw(scene).polygon([(0, 70), (120, 40), (240, 90), (240, 160), (0, 160)], fill=(60, 70, 60)) scene.save(self.d / "up.png") save_oriented(scene, 6, self.d / "o6.png") save_oriented(scene, 3, self.d / "o3.png") out = {} for name in ("up", "o6", "o3"): self._run("terrain.py", "ridge", self.d / f"{name}.png", "--x0", 10, "--x1", 230, "--step", 20, "--out", self.d / f"r_{name}.json", "--png", self.d / f"r_{name}.png") out[name] = json.loads((self.d / f"r_{name}.json").read_text(encoding="utf-8")) for name in ("o6", "o3"): self.assertEqual(out[name]["image_size"], [w, h]) self.assertEqual(out[name]["frame"], "upright") self.assertEqual(out[name]["ridge"], out["up"]["ridge"], name) self.assertEqual(Image.open(self.d / f"r_{name}.png").tobytes(), Image.open(self.d / "r_up.png").tobytes(), name) def test_exif_gives_the_upright_size_only_when_it_differs_in_frame(self): self.control.save(self.d / "o1.jpg", quality=95) save_oriented(self.control, 6, self.d / "o6.jpg", fmt="JPEG", quality=95) save_oriented(self.control, 3, self.d / "o3.jpg", fmt="JPEG", quality=95) out = {} for name in ("o1", "o6", "o3"): out[name] = json.loads(self._run("exif.py", self.d / f"{name}.jpg").stdout.splitlines()[0]) self.assertEqual(sorted(out["o1"]), ["file", "size"]) # ordinary photos: output as before self.assertEqual((out["o6"]["size"], out["o6"]["orientation"], out["o6"]["upright_size"]), ([40, 60], 6, [60, 40])) self.assertEqual((out["o3"]["size"], out["o3"]["orientation"], out["o3"]["upright_size"]), ([60, 40], 3, [60, 40])) if _image.HEIF: save_oriented(self.control, 6, self.d / "h6.heic", fmt="HEIF", quality=-1) self.assertNotIn("orientation", json.loads(self._run("exif.py", self.d / "h6.heic").stdout.splitlines()[0])) def _fit_sheet(self, photo, ridge, out): import terrain rec = {"f": 200.0, "cam": [30.0, 120.0], "g": 0.0, "H": 0.0, "cc": 0.0, "hit": 1, "name": "x", "rms": 0.1, "flatpen": 0.0, "total": 0.1} args = SimpleNamespace(sheet_width=120, sheet_cols=1, range=1000, zoom=12, cache=self.d, proxy=None, allow_missing=False, eye=1.6, near=40) with patch.object(terrain, "DEM", side_effect=SystemExit("offline")), contextlib.redirect_stderr(io.StringIO()) as err: terrain._fit_sheet([rec], ridge, photo, out, args, np.arange(0, 360, 1.0), 8) with Image.open(out) as im: return im.tobytes(), err.getvalue() def test_fit_sheet_draws_old_ridge_records_on_the_stored_pixels(self): for w, h in ((240, 160), (160, 160)): scene = upright_control(w, h) scene.save(self.d / "up.png") for o in (3, 6): save_oriented(scene, o, self.d / f"o{o}.png") stored = stored_as(scene, o) stored.save(self.d / "plain.png") old = {"image_size": list(stored.size), "hrow": stored.height / 2, "cx": stored.width / 2, "flat": None, "ridge": [[10, 20], [stored.width - 30, 45]]} # ridge.json from before #29: no frame want, _ = self._fit_sheet(self.d / "plain.png", old, self.d / "s_plain.jpg") got, err = self._fit_sheet(self.d / f"o{o}.png", old, self.d / f"s_o{o}.jpg") self.assertEqual(got, want, f"{w}x{h} orientation {o}") self.assertIn("stored frame", err) new = dict(old, image_size=[w, h], hrow=h / 2, cx=w / 2, ridge=[[10, 20], [w - 30, 45]], frame="upright") want, _ = self._fit_sheet(self.d / "up.png", new, self.d / "s_up.jpg") got, err = self._fit_sheet(self.d / f"o{o}.png", new, self.d / f"s2_o{o}.jpg") self.assertEqual(got, want, f"{w}x{h} orientation {o}, upright record") self.assertNotIn("stored frame", err) def test_spacing_warns_when_an_old_ridge_meets_upright_columns(self): import geo save_oriented(self.control, 6, self.d / "o6.png") self.control.save(self.d / "up.png") pts = [[5, 10], [20, 12], [35, 9], [50, 14]] cases = {"old_o6": ({"photo": "o -
test_terrain.py 8.3 KB
#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = ["pillow", "numpy"] # /// """Offline terrain.py fit regressions on synthetic terrain. Run: uv run skills/geo-sleuth/tests/test_terrain.py""" from __future__ import annotations import contextlib import io import json import math from pathlib import Path import sys import tempfile import unittest from unittest.mock import patch import numpy as np sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) import terrain LAT0, LON0 = 38.0, 106.0 W, H, HROW = 2000, 1125, 600.0 class FakeDEM: """Flat plain; a low near range 6 km west that ends 1 km north of the camera; a high far range 20 km west.""" def __init__(self, *a, **k): pass def sample(self, lat, lon): y = (np.asarray(lat) - LAT0) * 110540 x = (np.asarray(lon) - LON0) * 111320 * math.cos(math.radians(LAT0)) crest = 1500 + 500 * np.sin(y / 2500) + 300 * np.cos(y / 1300) far = crest * np.exp(-((x + 20000) / 2500) ** 2) near = (350 + 120 * np.sin(y / 700)) * np.exp(-((x + 6000) / 400) ** 2) * (y < 1000) * (y > -4000) return np.maximum(far, near) def synth_ridge(path: Path, f35: float, heading: float, two_layer: bool) -> None: """Project the fake terrain through a pinhole at f35 / heading, write ridge.json with an assumed 26 mm focal.""" f = f35 / 43.2666 * math.hypot(W, H) xs = np.arange(40, W - 40, 80, dtype=float) az = (heading + np.degrees(np.arctan((xs - W / 2) / f))) % 360 ang, dist = terrain.cast(FakeDEM(), (LAT0, LON0), 1.6, az, 30000, near=150, n=600) row = lambda a: HROW - f * np.tan(np.radians(a)) far = row(ang.max(axis=1)) near_ang = ang[:, dist <= 9000].max(axis=1) near = row(near_ang) ridge = {"photo": "", "image_size": [W, H], "cx": W / 2, "ridge": [[int(x), int(round(y))] for x, y in zip(xs, far)], "flat": None, "hrow": HROW, "f0": 26 / 43.2666 * math.hypot(W, H), "f0_source": "assumed", "f35_equiv": 26.0, "near": [], "near_clear": []} if two_layer: stand = near_ang > 0.3 # columns where the near range stands up ridge["near"] = [[int(x), int(round(y))] for x, y, s in zip(xs, near, stand) if s] clear = xs[~stand & (xs > W / 2)] ridge["near_clear"] = [[int(clear.min()), int(clear.max()), float(row(0.25))]] path.write_text(json.dumps(ridge)) def fit(ridge: Path, out: Path, *extra: str) -> tuple[dict, str]: argv = ["terrain.py", "fit", "--at", f"{LAT0},{LON0}", "--radius", "0", "--ridge", str(ridge), "--out", str(out), "--range", "30000", "--min-peak", "2", "--nsamp", "400", *extra] err = io.StringIO() with patch.object(terrain, "DEM", FakeDEM), patch.object(sys, "argv", argv), contextlib.redirect_stderr(err): terrain.main() return json.loads(out.read_text(encoding="utf-8")), err.getvalue() class FocalTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.d = Path(self.tmp.name) def tearDown(self): self.tmp.cleanup() def test_assumed_focal_searches_wide_and_finds_telephoto(self): synth_ridge(self.d / "r.json", 60, 270, two_layer=False) res, _ = fit(self.d / "r.json", self.d / "f.json") best = res["cams"][0] self.assertGreater(len(res["params"]["f35_ladder"]), 10) self.assertLess(abs(math.log(best["f35"] / 60)), math.log(1.2)) self.assertLess(abs((best["H"] - 270 + 180) % 360 - 180), 1.0) self.assertIsNone(res["focal_edge"]) def test_main_camera_only_hits_long_edge_and_warns(self): synth_ridge(self.d / "r.json", 60, 270, two_layer=False) res, err = fit(self.d / "r.json", self.d / "f.json", "--focal-scales", "0.9,1,1.12") self.assertEqual(res["focal_edge"]["top1"], "long") self.assertIn("--f35-range", err) def test_widened_range_clears_the_edge(self): synth_ridge(self.d / "r.json", 60, 270, two_layer=False) res, _ = fit(self.d / "r.json", self.d / "f.json", "--f35-range", "20:40") self.assertEqual(res["focal_edge"]["top1"], "long") res, _ = fit(self.d / "r.json", self.d / "f.json", "--f35-range", "24:80") self.assertIsNone(res["focal_edge"]) def test_two_layer_scores_near_points(self): synth_ridge(self.d / "r.json", 60, 270, two_layer=True) ridge = json.loads((self.d / "r.json").read_text(encoding="utf-8")) self.assertGreaterEqual(len(ridge["near"]), 3) res, _ = fit(self.d / "r.json", self.d / "f.json") best = res["cams"][0] self.assertIn("D1", best) self.assertLess(best["rms_near_px"], 6) self.assertLess(abs((best["H"] - 270 + 180) % 360 - 180), 1.0) def test_perp_line_restricts_heading(self): synth_ridge(self.d / "r.json", 60, 270, two_layer=False) line = {"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "LineString", "coordinates": [[LON0 + 0.003, LAT0 - 0.02], [LON0 + 0.003, LAT0 + 0.02]]}}]} (self.d / "l.geojson").write_text(json.dumps(line)) res, _ = fit(self.d / "r.json", self.d / "f.json", "--perp-line", str(self.d / "l.geojson"), "--perp-tol", "10") self.assertLess(abs((res["cams"][0]["H"] - 270 + 180) % 360 - 180), 10.5) self.assertEqual(res["cams"][0]["line_brg"], 0.0) def _lines(self, parts): p = self.d / "l.geojson" p.write_text(json.dumps({"type": "FeatureCollection", "features": [ {"type": "Feature", "properties": {}, "geometry": {"type": "LineString", "coordinates": c}} for c in parts]})) return str(p) def test_perp_line_distance_ignores_vertex_density(self): synth_ridge(self.d / "r.json", 60, 270, two_layer=False) lon = LON0 + 0.0005 a, _ = fit(self.d / "r.json", self.d / "a.json", "--perp-line", self._lines([[[lon, LAT0 - 0.002], [lon, LAT0 + 0.04]]])) b, _ = fit(self.d / "r.json", self.d / "b.json", "--perp-line", self._lines([[[lon, LAT0 - 0.002], [lon, LAT0], [lon, LAT0 + 0.04]]])) self.assertEqual(a["n_cams"], 1) self.assertEqual(b["n_cams"], 1) def test_perp_line_picks_nearest_segment_not_nearest_midpoint(self): synth_ridge(self.d / "r.json", 60, 270, two_layer=False) lon = LON0 + 0.0005 p = self._lines([[[lon, LAT0 - 0.0001], [lon, LAT0 + 0.005]], [[LON0 - 0.002, LAT0 + 0.001], [LON0 + 0.002, LAT0 + 0.001]]]) res, _ = fit(self.d / "r.json", self.d / "f.json", "--perp-line", p, "--perp-tol", "10") self.assertEqual(res["cams"][0]["line_brg"], 0.0) def test_near_dist_within_sight_line_start_is_dropped_not_crash(self): synth_ridge(self.d / "r.json", 60, 270, two_layer=True) res, err = fit(self.d / "r.json", self.d / "f.json", "--near", "4000") self.assertEqual(res["n_cams"], 1) self.assertIn("dropped", err) with self.assertRaises(SystemExit): fit(self.d / "r.json", self.d / "f.json", "--near", "4000", "--near-dist", "3000") def test_every_rung_recovers_heading(self): for f35 in (13, 20, 26, 40, 90, 135): with self.subTest(f35=f35): synth_ridge(self.d / "r.json", f35, 270, two_layer=False) res, _ = fit(self.d / "r.json", self.d / "f.json") self.assertLess(abs((res["cams"][0]["H"] - 270 + 180) % 360 - 180), 2) class RidgeTests(unittest.TestCase): def test_points_and_near_layer_written(self): from PIL import Image with tempfile.TemporaryDirectory() as t: p = Path(t) / "p.jpg" Image.new("RGB", (400, 300), (120, 160, 200)).save(p) out = Path(t) / "r.json" with patch.object(sys, "argv", ["terrain.py", "ridge", str(p), "--points", "10,100;200,90;390,110", "--near", "10,140;100,150;180,160", "--near-clear", "250:390@170", "--out", str(out)]), \ contextlib.redirect_stdout(io.StringIO()): terrain.main() r = json.loads(out.read_text(encoding="utf-8")) self.assertEqual(r["ridge_source"], "points") self.assertEqual(r["ridge"], [[10, 100], [200, 90], [390, 110]]) self.assertEqual(len(r["near"]), 3) self.assertEqual(r["near_clear"], [[250, 390, 170.0]]) if __name__ == "__main__": unittest.main()
-
-
SKILL.md 26.7 KB
--- name: geo-sleuth description: Geolocate or chronolocate a photo with tool-verified reasoning (where was this taken / when was it taken / photo geolocation / geo-guessing). Given one or more photos, intake.py does metadata, OCR and reverse image search (Baidu/Yandex) in one command; clues and candidates go on a candidate board (board.py) that ranks them and gives the next step; clues.py looks up calling codes, driving side, plates and area codes from region packs; for satellite and street view the machine ranks first and you look only at the top few (CLIP in sat_scan.py, DINOv2+SIFT in match.py); plus sun and shadow math, OSM Overpass and DEM skylines. Every conclusion is checked against real data; output is coordinates + error radius, evidence images and tiered confidence. Use when the user shares a photo and asks where was this taken / geolocate this / when was this taken / 这是哪 / 在哪拍的 / 帮我定位这张照片 / 网络迷踪 / 图寻 / 几点拍的. --- # geo-sleuth (v2) Goal: find a location that **holds up under checking**. Every conclusion must point to the clue it used, the command it ran and the file it produced. > `${CLAUDE_SKILL_DIR}` in commands means the directory containing this SKILL.md (the same for commands in references). Claude Code substitutes it automatically; in other agents, first `export CLAUDE_SKILL_DIR=<absolute path of this directory>` before running (if the shell doesn't keep variables between commands, prefix every command with it), or replace it with that path directly. The work is split into three layers. Remember this first, then read the flow: | Layer | Who | Tools | |---|---|---| | Decision: which candidates exist, how evidence is scored, whether a candidate can be excluded, what to scan next | **Script** (candidate board) | `board.py` | | Perception: reading text, table lookups, finding targets in satellite imagery, street-view matching | **Scripts compute and rank first; you look only at the top few** | `intake.py` `ocr.py` `clues.py` `sat_scan.py` `match.py` `geo.py bearings` | | Judgment: pulling clues out of the image, proposing hypotheses when the tables have nothing, deciding among the machine's top few | **You** | — | The method comes from breaking down 14 photo-geolocation (网络迷踪) creator videos and 22 puzzles, plus several rounds of blind-test comparisons; the breakdown notes are not published with the repo. The lesson of v1: rules written as prose don't get executed, and two runs of the same skill version gave very different results; so v2 puts every rule that can be written as code into `board.py`. ## Hard rules (always in force; ★ = enforced by board.py, just follow it) 0. If the user says the photo isn't theirs, or the image shows a private residence or minors, ask once what it's for before continuing; when the prompt already states the source or purpose (an evaluation puzzle, a puzzle setter's hint, the user says they took it), don't ask, and go to the finest level as usual. 1. **Don't fabricate verification.** "Measured on the map", "matched street view", "±10 m" must correspond to commands actually run and files actually produced in this session. If you didn't run it, write "unverified". 2. **Precision needs a source**: an error radius ≤100 m requires the intersection of two independent constraints, or a ground-level match on ≥3 invariant features. Before reporting a camera position, run a self-check: `pose.py project --horizon <sea-horizon row>` computes what the pitch should be and each feature's "expected row" for the current camera position; reconcile that with the photo (the depression angle to a target ≠ the camera's pitch; look up the camera position's elevation with `terrain.py elev` — if the height difference doesn't match the depression angles in the image, the camera position is wrong). 3. **Once you have a unique anchor, close the loop**: all later search and geometry starts from the anchor; don't go back to generic features like "blue-green lake water" or "tropical park" and pick the most famous place of that kind. 4. **Compute bearings first, then identify structures**: which block in satellite imagery is the tower or chimney in the photo is itself an interpretation. First compute its true bearing in the image with `sun.py compass` or a confirmed landmark, then use `geo.py bearings` to see which outline around the candidate camera position falls on that bearing; when nothing fits, first try both templates ("original/mirrored", "sunrise/sunset"); if not verified, only lower the tier, don't exclude. 5. ★ **List the whole category before inferring**: when inferring a region from "hill city", "tropical", "IP in a municipality", use `board.py children` to add all subordinate admin divisions as candidates, then rank them with evidence. Don't default to the main urban district, don't pick by population. 6. **Metadata and hints are hypotheses**: when EXIF, IP location, location tags or the puzzle setter's words conflict with the image, the image wins; don't invent a story to reconcile them. IP says country A but the hint or the regulation designs (plates, signs) point to another continent: intersect with `clues.py lookup territories <country A> --continent <continent>`. 7. **Self-consistency**: redo each key judgment once with a different crop or keyword set; if the two results differ by tens of kilometers or more, lower the tier. 8. ★ **If you can't pin a point, give a range + what information is missing**; when the candidates are a few discrete ones, don't take the midpoint: the main answer of `board.py report` is always the top-ranked candidate; the rest go into alternatives with discriminating tests. 9. ★ **Exclusion and confirmation use the same standard**: `board.py exclude` only accepts read/computed clues + a computed file (output of `geo.py frame`, `terrain.py`, etc.); observed and inferred clues can only down-weight via `evidence --against`, with the likelihood ratio clamped to 1/3–3 (inferred) or 1/5–5 (observed). Generating candidates from "there's an X nearby" is also filtering, and X must be something confirmed in the image. Fine-level candidates such as campuses, residential compounds and street segments must go on the board too: add a batch at once with `board.py add --from <poi.py --out / osm.py geom output> --level area/road`, don't hand-pick; when you drop one, write `evidence --against` with a comparison image, and `check` lists candidates without a single piece of evidence (meaning nobody looked at them); a finer candidate goes under the one that contains it (a point under its street segment: `--parent`, or `board.py move`), so its support counts in that level's ranking, and `check` fails when it skips a level; labels from third-party data (tree species in a municipal inventory, an exact school-name match) can only down-weight, never justify exclusion (in two cases the ground truth was skipped by hand exactly this way). **The excluded extent must be ≤ the evidence extent**: for candidates with extent, such as districts, areas and roads, `exclude` needs `--covers lat,lon[:lat,lon]` stating which stretch the evidence covers; the script rejects coverage below half (excluding a whole road after looking at one point on it failed in two cases). 10. ★ **Population and fame are not evidence**; scan order follows "share ÷ pages" (`board.py next`): finish the small districts first, put large districts last with a page cap. ## Flow You don't have to go through every step: if reverse image search in step 1 hits directly, jump to steps 6 and 7 to confirm. Every step's output goes on the candidate board. ### Step 1: steps 0–3 in one command ```bash uv run ${CLAUDE_SKILL_DIR}/scripts/intake.py photo.jpg --out-dir intake/ [--box x0,y0,x1,y1 ...] uv run ${CLAUDE_SKILL_DIR}/scripts/board.py init --photo photo.jpg ``` `intake.py` usually takes 1–2 minutes, longer on the first run because it installs dependencies: give the command a generous timeout (10+ minutes) or run it in the background. If a command timeout interrupts it, the reverse-image-search subprocesses may still be writing into `rev/`, but `intake.md` won't be generated; don't treat it as finished. `intake.md` contains: metadata, OCR text (lines read from upscaled/tiled passes are marked pass=up/tile and are hypotheses), Baidu similar images (source-site counts + numbered contact sheet), reverse-image-search labels with tiered vote counts, likely residential compound/development names, the list of edge crops, and failed items. Then you do four things: - **Look at the image**: go through every crop in `edges/` (four edges, four corners); run through the checklist in `references/observe.md`; log each clue with `board.py clue "<text>" --kind <kind> --status observed|read|inferred|computed --file <zoomed crop>`. Be honest about status: text you read is read; "the building is probably 8 floors", "the road goes uphill" are inferred. - When a computed result actually tests an earlier clue, link it with `clue "<result>" --kind <kind> --status computed --file <output> --resolves K1` (repeat `--resolves` for multiple clues). An unrelated computation does not resolve a pending infrastructure clue; this link records the test, not support for a location. - **Lookup tables**: calling codes, driving side, overseas territories are global; plates, area codes and other country tables come from region packs → `clues.py lookup <kind> <value> [--country <cc>]` (without `--country`, every pack is searched and matches from several countries are all returned). For those that resolve to an admin division, use `board.py apply --kind plate --value <plate prefix> --file <zoomed crop>` directly (adds candidates and evidence automatically; the other candidates at the same level are only down-weighted, not excluded). - **Reverse-image-search results**: first open `rev/<name>_baidu_similar.jpg` (the top-left tile is the query image) and look for near-duplicates of the same object or the same scene; if there are any, go by number to `similar[i].from` in the JSON to see the source page; compound, development or hotel names in the labels → `poi.py "<name>" --city <city> --out pois.json` to get coordinates; for same-name hits (several campuses, several branches) put them all on the board with `board.py add --from pois.json --level area`, then check; always open the screenshots, and once a post hits, look through the rest of its photo set. Choose where to search by object type (`references/search.md`). - **Hints and metadata**: log each as an inferred clue and state how credible it is; IP location only says where the person was when posting, and the posting time is not the capture time. - **Country-specific material**: country-specific clues, tables, preferred services and tips are in region packs (`regions/<cc>/`). Once a country is a candidate, run `regions.py show <cc>`: it prints the pack's lookups, services and a one-line index of its clue entries with line numbers; read only the entries you need. When the country is settled, `board.py country <cc>`. `regions.py list` shows which packs exist; with no pack, use the global tools and `references/clues/global.md`. ### Step 2: list all candidates, score evidence, check the next step ```bash uv run ${CLAUDE_SKILL_DIR}/scripts/board.py children <parent admin area> # municipality → all districts; country → first-level admin divisions (gazetteer.py queries OSM, with bbox) uv run ${CLAUDE_SKILL_DIR}/scripts/board.py evidence --clue K1 --for <candidate A>:5 --for <candidate B>:2 --against <candidate C>:0.3 --why "…" --file <comparison image> uv run ${CLAUDE_SKILL_DIR}/scripts/board.py rank uv run ${CLAUDE_SKILL_DIR}/scripts/board.py next ``` `next` only ever says one of two things: - **Can't separate**: run discriminating tests from cheap to expensive, each one on all candidates at once — lookup tables → terrain (plain vs. hill city, `tiles.py fetch --zoom 13` or `terrain.py`) → vehicle livery (image search for "<city> <color> bus" in the local language; read route signs from the result images, compare the stripe along the bus rear by district) → street fixtures (street view per candidate built-up area) → river/road-network templates. Still can't separate after all of them: don't stop; scan in the "share ÷ pages" order it gives. `next` replaces these lines with the region pack's tips when the board's country is known. - **Ready to narrow**: first narrow the extent to the built-up area with `board.py urban <candidate>` (or give it by hand with `scan-bbox`), then write a falsification condition with `board.py falsify <candidate> --text "…"`, then go to step 3. The coarse environmental-location rules still apply: terrain before river width and building color; phenology must be paired with the month; take the intersection of rare facility combinations; recognizable species are only an exclusion tool; when all you have is a land type, first narrow to that type of land with a land-cover map (`references/clues/`). ### Step 3: pick a branch to narrow down | What you have | Approach | Read | |---|---|---| | A unique anchor (building, statue, tower, scenic-area building) | Anchor geometry: sight-line intersection, alignment lines, tangent lines, filtering buildings by camera height | `geometry.md` | | Distant mountains, skyline | Render candidate camera positions with `terrain.py view` and compare; this fixes only one sight line, then find a second constraint. No EXIF: don't assume the main camera (distant mountains are often zoomed); a best focal on the edge of the searched range means widen and rerun. Near range in front of a far one: score both layers | `geometry.md` | | Clear shadows, lit faces, the sun in the frame | `sun.py locate / when / facing / compass`: latitude band, time, heading, true bearing of objects in the image | `sky.md` | | Route numbers, railway or power-line specs, a large river | Linear corridor + line-to-point: `osm.py route / crossings / along` | `corridors.md` | | Two or three kinds of infrastructure in one frame | `osm.py near --report`; crossings of two linear types `osm.py intersect` (bends are only annotated, not removed) | `corridors.md` | | Linear infrastructure + unrecognized mountains, no text | Compute horizons from elevation along the infrastructure and filter a whole region → score skyline + distance to infrastructure numerically → overlay the top 3–5 → fix the camera position from evenly spaced structures: `terrain.py scan --lines … --out hits.json --clusters-out clusters.json` → `terrain.py ridge photo.jpg --x0 … --x1 … --out ridge.json` → `terrain.py fit --hits hits.json --ridge ridge.json [--line … --line-dist …] --sheet top.jpg --photo photo.jpg` (for the fine search switch to `--at lat,lon --radius 800 --grid 100 --zoom 13`) → `imgprep.py piers photo.jpg --rows … --out cols.json --sheet piers.jpg` → `geo.py spacing --cols … --line … --span … --center lat,lon --ridge ridge.json` | `corridors.md` 4.3, `geometry.md` 7.4 / 7.7 | | Clear street layout, no anchor | `osm.py street-scan` to filter intersections → `tiles.py sheet` → street view | `corridors.md` | | No near-duplicates from reverse image search, no text, only a set of scene elements (road spec + adjacent features + landform) | **Don't hand-pick spots first**: write the scene as one English query and let CLIP rank with `sat_scan.py grid --bbox <coastal strip/corridor> --zoom 17 --cell 360 --query … --neg …`; look only at the top 20–30 thumbnails; `grid` beats `points` because OSM features like `amenity=parking` may be incomplete | `search.md` | | ≥4 points of known location in the frame (window views, looking down; also level shots of distant towers, bridges, piers — fix the height) | `pose.py solve` solves camera position, heading and height; when several candidate positions all fit, score them one by one with `pose.py check --cands`; only robust Δchi2 > 9 may go to `board.py exclude --computed` | `geometry.md` section 10 | | Want to exclude by "there's no X in the frame" | `geo.py frame` first computes whether X should be inside the frame, large enough and not occluded; only what it says can exclude goes to `board.py exclude --computed` | `geometry.md` section 11 | | An unnamed building of a recognizable class, search region narrowed | Enumerate with `osm.py find` → nearby views facing each building with `gsv.py targets` → rank with `match.py`; use bounded batches | `search.md` section 6 | | Recognizable facility type (grain dryer tower, feed mill, sugar mill, concrete batching plant) | Look up the industry's distribution → enumerate large buildings with `osm.py buildings` → rank with `sat_scan.py points` | `search.md` | | Stores of a chain's sub-brand | First search opening press releases for the address; use store locators only as a candidate pool → `osm.py along` + street-view sampling | `search.md` | | Plane window, drone shots looking down | Aerial branch | `aerial.md` | ### Step 4: finding the spot in satellite imagery — the machine ranks first ```bash uv run ${CLAUDE_SKILL_DIR}/scripts/sat_scan.py grid --bbox <scan_bbox> --zoom 17 --preset track --multi-scale --top 30 --out sat.json --sheet sat_top.jpg --heat heat.jpg uv run ${CLAUDE_SKILL_DIR}/scripts/poi.py "<district> <school, in the local language>" --city <city> --region <cc> --out schools.json # seeds: --seeds schools.json boosts nearby cells uv run ${CLAUDE_SKILL_DIR}/scripts/sat_scan.py points --points big.json --preset factory --out r.json --sheet r.jpg # rank candidate points from osm.py buildings / poi.py ``` - Presets: track (running tracks), stadium, factory, silo, dam, bridge, quarry, solar, greenhouse, port; custom `--query`. In tests: across 300-odd cells of an urban area, more than half of the OSM-mapped running tracks made the top 30; in an old town in China where OSM is blank, a school's sports field still ranked first. **It ranks, it doesn't decide**: look at the thumbnails of the top 20–30 cells, then check them against the bearing and shape in the photo. - Translate the image description into top-down features before looking: curved buildings, octagonal pavilion roofs, sports courts, parking spaces perpendicular to a railway; for tall buildings look at the base; imagery has a date. - With many candidates, make a candidate table: one row per point, one column per criterion directly visible in the photo. - Nothing in the top 30 matches: first go back to the "candidates down-weighted by inference" listed by `board.py check` and to the falsification conditions, then switch preset or go to z18, and only then widen the area. ### Step 5: confirmation — street view is ranked first too ```bash uv run ${CLAUDE_SKILL_DIR}/scripts/baidu_pano.py scan <lat,lon> --radius 300 --out panos.json # the street-view script the region card names (CN: baidu_pano.py); otherwise gsv.py uv run ${CLAUDE_SKILL_DIR}/scripts/match.py rank --query photo.jpg --panos panos.json --toward <landmark lat,lon> --spread 15 --refine sift --top 10 --out m.json --sheet m.jpg uv run ${CLAUDE_SKILL_DIR}/scripts/match.py rank --query photo.jpg --items around.index.json --render gsv --spread-headings -30,0,30 --out m.json --sheet m.jpg ``` - `match.py` coarse-ranks by DINOv2 global similarity and fine-ranks by SIFT inliers; in tests, ground-truth street views of the same place from different years all made the top 4. Open only the top 10 and compare **invariant features** (building outline, window positions, balconies, pole positions, curbs, ridgelines), not vehicles, signs or foliage. ≥2 features for road level, ≥3 for building level. Not a single image with ≥15 inliers **doesn't mean it's wrong**: with a change of season, an old capture, or the photo taken from the sidewalk while street view was shot from the middle of the road, the ground truth had only 5 and 0–8 inliers in tests (two cases); first open the top 10 and compare invariant features, and only if none fit change `--spread-headings` or widen `--within`. The global score gets dominated by season (a blossom-season capture ranks high wherever it is), so compare against a historical capture from the same season as the photo when you can (`gsv.py sheet --date`). - With many panorama points, group by date and road; `sheet --road <road name> --spread 60` shows one road only; older captures have a more open view. - No street view doesn't mean you can't confirm: compare ridgelines with `terrain.py view --photo`; find a name for the candidate facility (OSM name, nearby place name + the facility-type word in the local language) and search news, encyclopedias and official-site photos to compare facade details. - When street view is many years older than the photo, go by old buildings and permanent structures. ### Step 6: fix the camera position and write the output - Look back: at the matching street-view point, turn 180° and see what's on the photographer's side. - A camera position needs two independent constraints (`geo.py intersect` sight-line intersection, `geo.py line` alignment line, `pose.py` multi-point solve, camera height, reverse street view); with only one, building-level confidence is at most "medium". - When there's no measurable shadow, use lit faces for the heading: `sun.py facing --lit left --shaded camera`. For photos taken from a car, boat or train, state the direction of travel. ```bash uv run ${CLAUDE_SKILL_DIR}/scripts/board.py check # before concluding: do exclusions have files, does the main answer have verified evidence, which clues went unused uv run ${CLAUDE_SKILL_DIR}/scripts/board.py report --merge result.json # main answer = top-ranked; alternatives, exclusions and unused clues are written into result.json automatically uv run ${CLAUDE_SKILL_DIR}/scripts/evidence.py spec.json --out evidence.jpg ``` Show `evidence.jpg` to the user together with the answer, without waiting to be asked. A road-level claim from satellite imagery needs a ≥z18 crop, not a z17 glance. Output: 1. One-sentence conclusion: place + camera position + heading (+ direction of travel and capture time, where applicable) 2. Coordinates: WGS84, plus the local datum when the region card names one (`geo.py convert`), **with error radius** 3. Evidence image (satellite image marking the camera position and heading wedge + comparison images) 4. Reasoning chain: clue → inference → **commands actually run and files produced** → extent 5. Tiered confidence (table below). **Rate each level separately; the tier you report is the finest level rated "medium" or above**: a low building level doesn't drag down a high area level 6. Excluded candidates and reasons, alternatives and discriminating tests, clues not used or not resolved (generated by `board.py report`) 7. When you can't pin a point: which level is settled + what information is still needed Use English for tool messages, report headings and generated labels. Preserve source text (OCR, place names, service responses) verbatim as evidence; explain or translate it for the reader. Write the agent's final report in the user's language. In Chinese, use 高/中/低 for high/medium/low and 城市/片区/路/楼/楼层 for city/area/road/building/floor. | Level | High | Medium | Low | |---|---|---|---| | City | Text, plates, area codes or a confirmed anchor | Several independent weak clues agree | A single weak clue, or only the hint | | Area | A unique facility or landmark matches ≥3 top-down features in satellite imagery (whole candidate area checked, no second match) | The most similar spot in the candidate area, with candidates still unchecked | Inferred | | Road | Ground-level match on ≥2 unique features | Layout consistent, 1 unique feature; **when there is no ground-level imagery at all**: area level high + bearing self-check passed | Satellite layout only | | Building | Two independent constraints + ground-level match on ≥3 features | One of the two | Inferred | | Floor | Two kinds of reference agree | A single reference | Don't give | ## Budget and stopping conditions - `intake.py` counts as one call; if reverse image search with 3 different keyword sets finds nothing, stop and go back to the checklist for other clues. - **While candidates still can't be separated, don't scan any point one by one** (when `board.py next` says "can't separate", do the discriminating tests first); if they still can't be separated after the cheap tests, scan in the order it gives, the first 3 pages of each candidate's built-up area first. - Scanning and confirmation always rank first: look at the top 30 cells from `sat_scan.py` and the top 10 images from `match.py`; widen only when all of the top ones are wrong, instead of paging through. - Run `osm.py coverage` before enumerating candidates with OSM: districts with sparse data get silently excluded; switch to `sat_scan.py grid` and say so in the conclusion. - Scan panoramas in batches of at most 500 points per area. When a batch or several areas yield no match, review candidate generation, search coverage and down-weighted candidates; then change the approach or widen the justified search scope. A fixed number of unmatched areas is not a reason to end the investigation. - A failed or unavailable service blocks that route, not the whole investigation. Consider other feasible sources or methods; do not repeat unchanged failing requests. - Before stopping, `board.py check`: go back through the top 20 candidates, in ranking order, that were down-weighted by inference but not excluded. - Conclude when evidence supports the requested location level, the user asks to stop or a user-set budget is reached, or no remaining feasible action is likely to distinguish the candidates. If unresolved, use `board.py report` to state the supported level, actual search coverage, attempted routes, blockers and missing evidence; do not invent a coordinate or precision to finish. ## Runtime environment - Python 3.10+, `uv`, and `curl`. Always use `uv run ${CLAUDE_SKILL_DIR}/scripts/xxx.py`; each script declares its dependencies. `scripts/` in references is relative to this skill directory. - On first setup or after a runtime failure, run `uv run ${CLAUDE_SKILL_DIR}/scripts/doctor.py`; add `--network` to check service reachability. Read the English checks and fixes before starting an expensive scan. It uploads no photos and does not load ML models. - Reverse image search uses local Google Chrome, with automatic fallback to Playwright Chromium (`uvx playwright install chromium`). If neither starts, use `intake.py --no-rev` and report the skipped search. OCR prefers Apple Vision on macOS and uses RapidOCR elsewhere or as a fallback. - `match.py` and `sat_scan.py` install ML dependencies and download model weights on first use. Allow extra time and disk space. - Cache goes to `.geo-cache/` in the current directory; the candidate board is `board.json` in the current directory. Script list and data sources: `references/data-sources.md`. - macOS has no `timeout` command; in zsh `$var` doesn't word-split, so use `bash -c` or `${=var}` in loops. A province-wide Overpass query can take several minutes; run it in the background.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.