Jeg har fået mit program til at virke nogenlunde med GeoDanmarks nye API til ortofotos. Det var et interessant projekt -- idet der blev vist en vis interesse for nørderiet, er her nogen detaljer.
Ikke-nørder er velkomne til at springe over; der er ikke meget egentlig jernbanerelevans.
1. Kortprojektion. Det nye API leverer luftfotoene i UTM32-projektion, som er standard for dansk kortlægning, mens mit program laver alle sine interne beregninger i OpenStreetMaps "pseudo-Mercator" projektion.
UTM er sådan set en bedre kortprojektion, idet den er eksakt vinkeltro og strørrelseforholdet kun varierer med under 1% forskellige steder i landet. I et Mercator-kort adskiller størrelsesforholdet sig med over 8% mellem Gedser og Skagen, og
pseudo-Mercator er ikke engang eksakt vinkeltro; på vore breddegrader er der omkring 0.2% forskel på skalaen nord-syd og øst-vest. (Til gengæld falder "opad langs kortets akse" ikke ganske sammen med sand nord på et UTM-kort; det varierer fra omkring 3 grader øst i København til en halv grad vest i Esbjerg). Men når jeg nu gerne vil bruge OpenStreetMap som grundkort og skifte frit mellem GeoDanmark og Google Maps, kan jeg ikke bare gå over til UTM internt, så der er ingen vej uden om at at omregne koordinater.
På
Wikipedia fandt jeg fine omregningsformler med rigelig præcision. De koster imidlertid over tyve trigonometriske operationer per koordinatsæt man vil omregne, så det er for langsomt at gøre det for hver pixel man vil vise.
I stedet deler jeg kortvinduet op i felter på 256×256 pixels og laver koordinattransformationen en gang for midpunktet i hvert felt; for resten af feltet ekstrapolerer jeg fra resultatet i midpunktet (idet jeg holder styr på de afledede mht pseudo-Mercator koordinaterne gennem hele beregningen). Det viser sig, at så længe det punkt jeg omregner, ikke er længere end 8 km væk fra midpunktet, giver den lineære ekstrapolation kun fejl på under 1/1000 af afstanden til midtpunktet, dvs væsentligt mindre end størrelsen på en vist pixel. Og i skalaer på mere end 8 km pr 128 viste pixels er luftfotoene ikke relevante alligevel -- det er for småt til at se spor overhovedet.
2. Datamængder. Det nye API leverer fotoene i store TIFF-filer på 1×1 km med en pixelstørrelse på 12.5 cm. Hver fil fylder 80-95 MB og indeholder også samme 1 km-kvadrat med pixelstørrelser 25, 50, 100 og 200 cm, og hvert af disse lag er igen inddelt i mindre tiles på 512×512 pixels hver. De tabeller der siger hvor hver af alle disse tiles ligger alle i de første få kilobytes af TIFF-filen (formatet i sig selv
kræver ikke denne placering, men det har konsekvent været tilfældet under mine tests).
Så så snart jeg har set
starten af filen, kan jeg vide hvilken del af den jeg har brug for. Det hjælper mig umiddelbart ikke så meget, idet den server filen ligger på ikke tillader at man addresserer i den. Øv! Men det viser sig også at lagene med den højeste opløsning i praksis ligger
sidst i filen. Og til mit formål er 25 cm pixels normalt rigelig detaljerede, så jeg kan simpelthen afbryde downloadforbindelsen når så snart har set alle 25 cm tiles! Det sparer omkring 3/4 af downloadmænden, og jeg kan hente 1×1 km i 25 cm opløsning på omkring 10 sekunder -- det er hurtigt nok til at virke interaktivt.
Undervejs modtager jeg også de mere grovkornede lag meget hurtigt så jeg i det mindste kan vise
noget på skærmen før alle 10 sekunder er gået. (Her springer jeg over en masse morsomme mutex-problemer med at få min kode til at vise pixels fra en tile som samme kode tror den stadig er ved at downloade, men det lykkedes).
3. Mmap. Jeg måtte skrive mig min egen TIFF-parser som kan håndtere at den fil den skal fortolke ikke findes i fuld længde endnu, men det var ikke så slemt -- alt hvad jeg har brug for kan rummes indenfor 500 linjer kode, og så er det endda også noget mere generelt end jeg egentlig har brug for her.
Den virker ved at memory-mappe TIFF-filen; så er det let af følge de interne pointere som formatet anvender overalt, uden at skulle til at beregne udtrykkelige søge- og læseoperationer i filsystemet.
Det viser sig at selv om Java i årtier har haft et fint API til at
oprette et mmap, har det stadig ikke en officiel måde at
lukke det på. Man forventes at vente på at MappedByteBuffer-objektet bliver garbage collectet.
Oracle siger de ikke kan finde ud af at lave en close()-funktionalitet som på idiotsikker vis forhindrer at man forsøger at bruge sit mmap senere. Og idet vi ikke kan få en perfekt løsning, kan vi åbenbart ikke få en dokumenteret løsning overhovedet. Hvis man vil undgå at løbe tør for adresserum fordi spildopsamleren tager den lidt med ro en dag, er man nødt til at bruge sun.misc.Unsafe, og så tage den fordømmende tone i dokumentationen ovenfra og ned ...
4. Zip. Den eneste måde jeg kan finde ud af at få serveren til konsekvent at levere data i 12,5 cm opløsning i stedet for 10 og 12½ cm alt efter hvad der findes, er ved at bruge et API der siger "giv mig alle kvadrater indenfor sådan-og-sådan et område". Selv når jeg
ved at der kun er et kvadrat i området, får jeg ikke den rå TIFF-fil, men en ZIP der indeholder netop den fil.
Meget vel -- heldigvis forsøger Zip ikke at komprimere TIFF-filen men gemmer den bare som "rå bytes". Så blot ved at skrælle ZIP-headeren af kan jeg straks give mig til at fortolke TIFF-indholdet, mens downloaden stadig er i gang.
Zip er et frygteligt format. Det er designet til at blive skrevet sekventielt, så en del metadata om hver af filerne (herunder dens længde!) findes i princippet kun i et "central directory" i
slutningen af zip-filen. Det duer ikke når jeg har afbrudt min download når kun det kvarte af filen er kommet hjem! (Ingen af de standardværktøjer jeg har forsøgte mig med, har overhovedet ville genkende de delvise downloads som zip-filer).
Der er dog en header i begyndelsen af Zip, og den indeholder lige akkurat nok data til at man kan konkludere "dette er en zip-fil, og den første fil indeni hedder det-og-det og er gemt som rå bytes, men vi ved ikke hvor lang den ser", så håbe på det bedste. Hvis der var mere end én fil i zipfilen og det ikke var den første jeg havde brug for, ville jeg være fortabt ...
5. Jpeg. Hvad min TIFF parser giver mig er addressen og længden af en bytefølge der indeholder det ønskede 512×512 tile indkodet i JPEG-format. Så det kan jeg bare smide ind i en JPEG-dekoder, ikke?
Tjah. For det første er de første 73 bytes af JPEG-bytestrømmen (som indeholder "quantization tables") gemt separat i TIFF-filen, idet alle 512×512 tiles i et zoom-lag deles om disse bytes. (Hurra for den besparelse!) Så dem skal man lige huske at sætte ind i hvad man giver til JPEG-dekoderen.
For det andet indeholder en rå JPEG-bytestrøm ikke nogen standardiseret information om hvordan de fire datakanaler i den skal fortolkes som pixels -- nemlig i dette tilfælde rød, grøn, blå og infrarødt. Hvis det havde været en selvstændig .jpg-fil, vil den normalt starte med en metadata-header der bl.a. fortæller noget om det (der findes mindst to forskellige standarder disse metadata: JFIF eller Exif), men i dette tilfælde er fortolkningen beskrevet i TIFF-indpakningen, og udeladt fra JPEG.
Hvad sker der så når man lader en generisk JPEG-dekoder fortolke denne bytestrøm? I Java tror
ImageIO.read(ImageInputStream) at de første tre kanaler er i YCbCr-farveformat, hvilket den hjælpsomt giver sig til at konvertere til RGB; det ser ikke kønt ud.
På den anden side tror de andre grafikværktøjer jeg har prøvet (ImageMagick, NetPBM, Gimp), at de fire kanaler er RGB efterfulgt af
gennemsigighed, og deres uddata er så det delvist gennemsigtige billede kopieret ind over en sort baggrund! Det ser ikke helt så slemt ud -- i det mindste er farverne rigtige, blot for mørke -- men stadig ikke godt.
Efter en del frustration fandt jeg en måde at bruge Javas JPEG-dekoder så den overlader farvekonverteringen til mig -- og jeg ved hvordan den skal ske, ihvertfald for de specifikke filer jeg får fra GeoDanmark...
6. Adgangskontrol. Selvom ortofotoene er gratis og under CC-BY-4.0 licens skal man stadig bruge en API-key for at kunne downloade dem fra Klimadatastyrelsen. Man kan lave sig sådan en på et par minutter ved at logge ind på
portal.datafordeler.dk med MitID, men jeg tør ikke rigtig offentliggøre den på GitHub, så indtil videre virker det kun hvis brugeren selv skaffer sig en nøgle og lægger den i en lokal konfigurationsfil.
(På den anden side er jeg næsten sikker på at antallet af brugere der ikke er mig, er 0, så det går nok endda).