Feat: Use panoramax to upload to. Will contain bugs

This commit is contained in:
Pieter Vander Vennet 2024-09-26 19:15:20 +02:00
parent cf74296d23
commit 0bdc1aec61
19 changed files with 325 additions and 193 deletions

View file

@ -5,6 +5,7 @@ import GenericImageProvider from "./GenericImageProvider"
import { Store, UIEventSource } from "../UIEventSource"
import ImageProvider, { ProvidedImage } from "./ImageProvider"
import { WikidataImageProvider } from "./WikidataImageProvider"
import Panoramax from "./Panoramax"
/**
* A generic 'from the interwebz' image picker, without attribution
@ -28,6 +29,7 @@ export default class AllImageProviders {
Mapillary.singleton,
WikidataImageProvider.singleton,
WikimediaImageProvider.singleton,
Panoramax.singleton,
AllImageProviders.genericImageProvider,
]
public static apiUrls: string[] = [].concat(
@ -41,6 +43,7 @@ export default class AllImageProviders {
mapillary: Mapillary.singleton,
wikidata: WikidataImageProvider.singleton,
wikimedia: WikimediaImageProvider.singleton,
panoramax: Panoramax.singleton
}
private static _cache: Map<string, UIEventSource<ProvidedImage[]>> = new Map<
string,
@ -66,6 +69,9 @@ export default class AllImageProviders {
return AllImageProviders.genericImageProvider
}
/**
* Tries to extract all image data for this image
*/
public static LoadImagesFor(
tags: Store<Record<string, string>>,
tagKey?: string[]

View file

@ -36,7 +36,7 @@ export default abstract class ImageProvider {
prefixes?: string[]
}
): UIEventSource<ProvidedImage[]> {
const prefixes = options?.prefixes ?? this.defaultKeyPrefixes
const prefixes = Utils.Dedup(options?.prefixes ?? this.defaultKeyPrefixes)
if (prefixes === undefined) {
throw "No `defaultKeyPrefixes` defined by this image provider"
}
@ -46,6 +46,9 @@ export default abstract class ImageProvider {
const seenValues = new Set<string>()
allTags.addCallbackAndRunD((tags) => {
for (const key in tags) {
if(key === "panoramax"){
console.log("Inspecting", key,"against", prefixes)
}
if (!prefixes.some((prefix) => key.startsWith(prefix))) {
continue
}

View file

@ -9,6 +9,8 @@ import { Changes } from "../Osm/Changes"
import Translations from "../../UI/i18n/Translations"
import NoteCommentElement from "../../UI/Popup/Notes/NoteCommentElement"
import { Translation } from "../../UI/i18n/Translation"
import { IndexedFeatureSource } from "../FeatureSource/FeatureSource"
import { GeoOperations } from "../GeoOperations"
/**
* The ImageUploadManager has a
@ -17,7 +19,8 @@ export class ImageUploadManager {
private readonly _uploader: ImageUploader
private readonly _featureProperties: FeaturePropertiesStore
private readonly _layout: LayoutConfig
private readonly _indexedFeatures: IndexedFeatureSource
private readonly _gps: Store<GeolocationCoordinates | undefined>
private readonly _uploadStarted: Map<string, UIEventSource<number>> = new Map()
private readonly _uploadFinished: Map<string, UIEventSource<number>> = new Map()
private readonly _uploadFailed: Map<string, UIEventSource<number>> = new Map()
@ -32,13 +35,17 @@ export class ImageUploadManager {
uploader: ImageUploader,
featureProperties: FeaturePropertiesStore,
osmConnection: OsmConnection,
changes: Changes
changes: Changes,
gpsLocation: Store<GeolocationCoordinates | undefined>,
allFeatures: IndexedFeatureSource,
) {
this._uploader = uploader
this._featureProperties = featureProperties
this._layout = layout
this._osmConnection = osmConnection
this._changes = changes
this._indexedFeatures = allFeatures
this._gps = gpsLocation
const failed = this.getCounterFor(this._uploadFailed, "*")
const done = this.getCounterFor(this._uploadFinished, "*")
@ -47,7 +54,7 @@ export class ImageUploadManager {
(startedCount) => {
return startedCount > failed.data + done.data
},
[failed, done]
[failed, done],
)
}
@ -96,7 +103,7 @@ export class ImageUploadManager {
public async uploadImageAndApply(
file: File,
tagsStore: UIEventSource<OsmTags>,
targetKey?: string
targetKey?: string,
): Promise<void> {
const canBeUploaded = this.canBeUploaded(file)
if (canBeUploaded !== true) {
@ -105,28 +112,15 @@ export class ImageUploadManager {
const tags = tagsStore.data
const featureId = <OsmId>tags.id
const licenseStore = this._osmConnection?.GetPreference("pictures-license", "CC0")
const license = licenseStore?.data ?? "CC0"
const matchingLayer = this._layout?.getMatchingLayer(tags)
const title =
matchingLayer?.title?.GetRenderValue(tags)?.Subs(tags)?.textFor("en") ??
tags.name ??
"https//osm.org/" + tags.id
const description = [
"author:" + this._osmConnection.userDetails.data.name,
"license:" + license,
"osmid:" + tags.id,
].join("\n")
const author = this._osmConnection.userDetails.data.name
const action = await this.uploadImageWithLicense(
featureId,
title,
description,
author,
file,
targetKey,
tags?.data?.["_orig_theme"]
tags?.data?.["_orig_theme"],
)
if (!action) {
@ -146,23 +140,30 @@ export class ImageUploadManager {
private async uploadImageWithLicense(
featureId: OsmId,
title: string,
description: string,
author: string,
blob: File,
targetKey: string | undefined,
theme?: string
theme?: string,
): Promise<LinkImageAction> {
this.increaseCountFor(this._uploadStarted, featureId)
const properties = this._featureProperties.getStore(featureId)
let key: string
let value: string
let location: [number, number] = undefined
if (this._gps.data) {
location = [this._gps.data.longitude, this._gps.data.latitude]
}
if (location === undefined || location?.some(l => l === undefined)) {
const feature = this._indexedFeatures.featuresById.data.get(featureId)
location = GeoOperations.centerpointCoordinates(feature)
}
try {
;({ key, value } = await this._uploader.uploadImage(title, description, blob))
;({ key, value } = await this._uploader.uploadImage(blob, location, author))
} catch (e) {
this.increaseCountFor(this._uploadRetried, featureId)
console.error("Could not upload image, trying again:", e)
try {
;({ key, value } = await this._uploader.uploadImage(title, description, blob))
;({ key, value } = await this._uploader.uploadImage(blob, location, author))
this.increaseCountFor(this._uploadRetriedSuccess, featureId)
} catch (e) {
console.error("Could again not upload image due to", e)

View file

@ -1,15 +1,14 @@
import { Feature } from "geojson"
export interface ImageUploader {
maxFileSizeInMegabytes?: number
/**
* Uploads the 'blob' as image, with some metadata.
* Returns the URL to be linked + the appropriate key to add this to OSM
* @param title
* @param description
* @param blob
*/
uploadImage(
title: string,
description: string,
blob: File
blob: File,
currentGps: [number,number],
author: string
): Promise<{ key: string; value: string }>
}

View file

@ -3,14 +3,12 @@ import BaseUIElement from "../../UI/BaseUIElement"
import { Utils } from "../../Utils"
import Constants from "../../Models/Constants"
import { LicenseInfo } from "./LicenseInfo"
import { ImageUploader } from "./ImageUploader"
export class Imgur extends ImageProvider implements ImageUploader {
export class Imgur extends ImageProvider {
public static readonly defaultValuePrefix = ["https://i.imgur.com"]
public static readonly singleton = new Imgur()
public readonly name = "Imgur"
public readonly defaultKeyPrefixes: string[] = ["image"]
public readonly maxFileSizeInMegabytes = 10
public static readonly apiUrl = "https://api.imgur.com/3/image"
public static readonly supportingUrls = ["https://i.imgur.com"]
private constructor() {
@ -21,40 +19,6 @@ export class Imgur extends ImageProvider implements ImageUploader {
return [Imgur.apiUrl]
}
/**
* Uploads an image, returns the URL where to find the image
* @param title
* @param description
* @param blob
*/
public async uploadImage(
title: string,
description: string,
blob: File
): Promise<{ key: string; value: string }> {
const apiUrl = Imgur.apiUrl
const apiKey = Constants.ImgurApiKey
const formData = new FormData()
formData.append("image", blob)
formData.append("title", title)
formData.append("description", description)
const settings: RequestInit = {
method: "POST",
body: formData,
redirect: "follow",
headers: new Headers({
Authorization: `Client-ID ${apiKey}`,
Accept: "application/json",
}),
}
// Response contains stringified JSON
const response = await fetch(apiUrl, settings)
const content = await response.json()
return { key: "image", value: content.data.link }
}
SourceIcon(): BaseUIElement {
return undefined

View file

@ -1,14 +1,14 @@
export class LicenseInfo {
title: string = ""
title?: string = ""
artist: string = ""
license: string = undefined
licenseShortName: string = ""
usageTerms: string = ""
attributionRequired: boolean = false
copyrighted: boolean = false
credit: string = ""
description: string = ""
informationLocation: URL = undefined
license?: string = undefined
licenseShortName?: string = ""
usageTerms?: string = ""
attributionRequired?: boolean = false
copyrighted?: boolean = false
credit?: string = ""
description?: string = ""
informationLocation?: URL = undefined
date?: Date
views?: number
}

View file

@ -0,0 +1,158 @@
import { ImageUploader } from "./ImageUploader"
import { AuthorizedPanoramax } from "panoramax-js/dist"
import ExifReader from "exifreader"
import ImageProvider, { ProvidedImage } from "./ImageProvider"
import BaseUIElement from "../../UI/BaseUIElement"
import { LicenseInfo } from "./LicenseInfo"
import { Utils } from "../../Utils"
import { Feature, FeatureCollection, Point } from "geojson"
import { GeoOperations } from "../GeoOperations"
type ImageData = Feature<Point, { "geovisio:producer": string, "geovisio:license": string, "datetime": string }> & {
id: string,
assets: { hd: { href: string }, sd: { href: string } },
providers: {name: string}[]
}
export default class PanoramaxImageProvider extends ImageProvider {
public static readonly singleton = new PanoramaxImageProvider()
public defaultKeyPrefixes: string[] = ["panoramax", "image"]
public readonly name: string = "panoramax"
private static knownMeta: Record<string, ImageData> = {}
public SourceIcon(id?: string, location?: { lon: number; lat: number; }): BaseUIElement {
return undefined
}
public addKnownMeta(meta: ImageData){
console.log("Adding known meta for", meta.id)
PanoramaxImageProvider.knownMeta[meta.id] = meta
}
/**
* Tries to get the entry from the mapcomplete-panoramax instance. Might return undefined
* @param id
* @private
*/
private async getInfoFromMapComplete(id: string): Promise<{ data: ImageData, url: string }> {
const sequence = "6e702976-580b-419c-8fb3-cf7bd364e6f8" // We always reuse this sequence
const url = `https://panoramax.mapcomplete.org/api/collections/${sequence}/items/${id}`
const data = <any> await Utils.downloadJsonCached(url, 60 * 60 * 1000)
return {url, data}
}
private async getInfoFromXYZ(imageId: string): Promise<{ data: ImageData, url: string }> {
const url = "https://api.panoramax.xyz/api/search?limit=1&ids=" + imageId
const metaAll = await Utils.downloadJsonCached<FeatureCollection<Point>>(url, 1000 * 60 * 60)
const data= <any>metaAll.features[0]
return {data, url}
}
/**
* Reads a geovisio-somewhat-looking-like-geojson object and converts it to a provided image
* @param meta
* @private
*/
private featureToImage(info: {data: ImageData, url: string}) {
const meta = info.data
const url = info.url
if (!meta) {
return undefined
}
function makeAbsolute(s: string){
if(!s.startsWith("https://") && !s.startsWith("http://")){
const parsed = new URL(url)
return parsed.protocol+"//"+parsed.host+s
}
return s
}
const [lon, lat] = GeoOperations.centerpointCoordinates(meta)
return <ProvidedImage>{
id: meta.id,
url: makeAbsolute(meta.assets.sd.href),
url_hd: makeAbsolute(meta.assets.hd.href),
lon, lat,
key: "panoramax",
provider: this,
rotation: Number(meta.properties["view:azimuth"]),
date: new Date(meta.properties.datetime),
}
}
private async getInfoFor(id: string): Promise<{ data: ImageData, url: string }> {
const cached= PanoramaxImageProvider.knownMeta[id]
console.log("Cached version", id, cached)
if(cached){
return {data: cached, url: undefined}
}
try {
return await this.getInfoFromMapComplete(id)
} catch (e) {
return await this.getInfoFromXYZ(id)
}
}
public async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
return [this.getInfoFor(value).then(r => this.featureToImage(<any>r))]
}
public async DownloadAttribution(providedImage: { url: string; id: string; }): Promise<LicenseInfo> {
const meta = await this.getInfoFor(providedImage.id)
return {
artist: meta.data.providers.at(-1).name, // We take the last provider, as that one probably contain the username of the uploader
date: new Date(meta.data.properties["datetime"]),
licenseShortName: meta.data.properties["geovisio:license"],
}
}
public apiUrls(): string[] {
return ["https://panoramax.mapcomplete.org", "https://panoramax.xyz"]
}
}
export class PanoramaxUploader implements ImageUploader {
private readonly _panoramax: AuthorizedPanoramax
constructor(url: string, token: string) {
this._panoramax = new AuthorizedPanoramax(url, token)
}
async uploadImage(blob: File, currentGps: [number, number], author: string): Promise<{
key: string;
value: string;
}> {
const tags = await ExifReader.load(blob)
const hasDate = tags.DateTime !== undefined
const hasGPS = tags.GPSLatitude !== undefined && tags.GPSLongitude !== undefined
const [lon, lat] = currentGps
const p = this._panoramax
const defaultSequence = (await p.mySequences())[0]
const img = <ImageData> await p.addImage(blob, defaultSequence, {
lat: !hasGPS ? lat : undefined,
lon: !hasGPS ? lon : undefined,
datetime: !hasDate ? new Date().toISOString() : undefined,
exifOverride: {
Artist: author,
},
})
PanoramaxImageProvider.singleton.addKnownMeta(img)
await Utils.waitFor(1250)
return {
key: "panoramax",
value: img.id,
}
}
}

View file

@ -1,5 +1,5 @@
import { OsmNode, OsmObject, OsmRelation, OsmWay } from "./OsmObject"
import { Store, UIEventSource } from "../UIEventSource"
import { UIEventSource } from "../UIEventSource"
import Constants from "../../Models/Constants"
import OsmChangeAction from "./Actions/OsmChangeAction"
import { ChangeDescription, ChangeDescriptionTools } from "./Actions/ChangeDescription"
@ -11,7 +11,6 @@ import { GeoLocationPointProperties } from "../State/GeoLocationState"
import { GeoOperations } from "../GeoOperations"
import { ChangesetHandler, ChangesetTag } from "./ChangesetHandler"
import { OsmConnection } from "./OsmConnection"
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore"
import OsmObjectDownloader from "./OsmObjectDownloader"
import ChangeLocationAction from "./Actions/ChangeLocationAction"
import ChangeTagAction from "./Actions/ChangeTagAction"
@ -62,9 +61,9 @@ export class Changes {
this.backend = state.osmConnection.Backend()
this._reportError = reportError
this._changesetHandler = new ChangesetHandler(
state.dryRun,
state.featureSwitches.featureSwitchIsTesting,
state.osmConnection,
state.featurePropertiesStore,
state.featureProperties,
this,
(e, extramessage: string) => this._reportError(e, extramessage)
)

View file

@ -1,10 +1,6 @@
import { GeoOperations } from "./GeoOperations"
import { Utils } from "../Utils"
import opening_hours from "opening_hours"
import Combine from "../UI/Base/Combine"
import BaseUIElement from "../UI/BaseUIElement"
import Title from "../UI/Base/Title"
import { FixedUiElement } from "../UI/Base/FixedUiElement"
import LayerConfig from "../Models/ThemeConfig/LayerConfig"
import { CountryCoder } from "latlon2country"
import Constants from "../Models/Constants"

View file

@ -291,6 +291,8 @@ export default class NameSuggestionIndex {
if (location === undefined) {
return true
}
console.log("Resolving location", i.locationSet)
/*
const resolvedSet = NameSuggestionIndex.loco.resolveLocationSet(i.locationSet)
if (resolvedSet) {
@ -299,7 +301,7 @@ export default class NameSuggestionIndex {
const setFeature: Feature<MultiPolygon> = resolvedSet.feature
return turf.booleanPointInPolygon(location, setFeature.geometry)
}
*/
return false
})
}