profileShare

rasmusjy / countrysense

Read-only snapshot

No repository description.

main default branch 19 files Expires Sep 13, 2026, 9:06 AM
app.py 17,082 bytes
1 """Gradio demo: ennustab tänavapildi järgi riigi.
2
3 Kujundus laenab trükiatlaselt, sest leht ongi sisuliselt atlase register:
4 riikide loend arvudega. Soe kaardipaber, pruunikas graveeringutint, terrakota
5 reljeefivärv, koordinaadivõrk ainult päise all, ja register juhtpunktidega.
6
7 Kõik arvud loetakse treeningu väljunditest, et demo ei saaks lubada rohkem kui
8 mudel tegelikult oskab.
9 """
10
11 import json
12 import os
13 from pathlib import Path
14
15 import gradio as gr
16 import pandas as pd
17 import timm
18 import torch
19
20 from countrysense.data import build_transforms
21 from countrysense.model import load_checkpoint
22
23 CHECKPOINT = Path(os.environ.get("COUNTRYSENSE_CKPT", "runs/clip_vit_b16/best.pt"))
24 RUN_DIR = CHECKPOINT.parent
25
26 device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
27 model, classes = load_checkpoint(str(CHECKPOINT), device)
28 data_cfg = timm.data.resolve_model_data_config(model.backbone)
29 tfm = build_transforms(data_cfg["mean"], data_cfg["std"],
30 data_cfg["input_size"][-1], train=False)
31
32
33 def _read_metrics():
34 try:
35 return json.loads((RUN_DIR / "metrics.json").read_text(encoding="utf-8"))
36 except (OSError, ValueError):
37 return {}
38
39
40 def _read_per_country():
41 try:
42 df = pd.read_csv(RUN_DIR / "per_country.csv")
43 except (OSError, ValueError):
44 return pd.DataFrame(columns=["country", "accuracy", "support"])
45 return df.sort_values("accuracy", ascending=False).reset_index(drop=True)
46
47
48 METRICS = _read_metrics()
49 PER_COUNTRY = _read_per_country()
50
51
52 def _pct(value, digits=1):
53 return f"{value * 100:.{digits}f}".replace(".", ",") + "%"
54
55
56 # ── Ennustus ──────────────────────────────────────────────────────────────────
57
58 EMPTY = '<p class="empty">Lisa kõrvale tänavapilt.</p>'
59
60
61 def predict(img):
62 if img is None:
63 return EMPTY
64
65 with torch.no_grad():
66 probs = model(tfm(img.convert("RGB")).unsqueeze(0).to(device)).softmax(1)[0]
67 top = probs.topk(min(5, len(classes)))
68 ranked = [(classes[i], p) for p, i in
69 zip(top.values.tolist(), top.indices.tolist())]
70
71 lead, lead_p = ranked[0]
72 note = ("kindel määrang" if lead_p >= 0.7 else
73 "kõhklev määrang" if lead_p >= 0.4 else "nõrk määrang, ära usalda")
74
75 rest = "".join(
76 f"""
77 <li class="alt">
78 <span class="alt__name">{country}</span>
79 <span class="alt__bar"><i style="width:{p * 100:.1f}%"></i></span>
80 <span class="alt__pct num">{_pct(p)}</span>
81 </li>
82 """
83 for country, p in ranked[1:]
84 )
85
86 return f"""
87 <div class="out">
88 <p class="eyebrow eyebrow--mark">Määrang</p>
89 <p class="out__lead">{lead}</p>
90 <p class="out__meta"><span class="num">{_pct(lead_p)}</span> &middot; <em>{note}</em></p>
91 <ul class="alts">{rest}</ul>
92 </div>
93 """
94
95
96 # ── Staatiline sisu ───────────────────────────────────────────────────────────
97
98 def _head_html():
99 stats = [
100 ("top-1", _pct(METRICS["top1"]) if "top1" in METRICS else "—"),
101 ("top-5", _pct(METRICS["top5"]) if "top5" in METRICS else "—"),
102 ("makro-F1", f"{METRICS['macro_f1']:.3f}".replace(".", ",")
103 if "macro_f1" in METRICS else "—"),
104 ("klasse", str(METRICS.get("n_classes", len(classes)))),
105 ("testipilte", f"{METRICS['n_test']:,}".replace(",", " ")
106 if "n_test" in METRICS else "—"),
107 ]
108 cells = "".join(
109 f'<div class="stat"><p class="eyebrow">{label}</p>'
110 f'<p class="stat__v num">{value}</p></div>'
111 for label, value in stats
112 )
113 return f"""
114 <header class="mast">
115 <div class="mast__grid" aria-hidden="true"></div>
116 <div class="mast__body">
117 <p class="eyebrow eyebrow--mark">Peenhäälestatud CLIP ViT-B/16</p>
118 <h1 class="mast__title">CountrySense</h1>
119 <p class="mast__lede">
120 Määrab ühe tänavapildi järgi riigi. <em>Riigi tase, mitte koordinaadid.</em>
121 Arvud all on mõõdetud kõrvale pandud testihulgal, mida mudel treeningu ajal
122 ei näinud.
123 </p>
124 </div>
125 <div class="stats">{cells}</div>
126 </header>
127 """
128
129
130 CAVEATS = [
131 ("Mudel valib alati mõne registris oleva riigi",
132 "Kui pilt on tehtud kuskil mujal, tuleb ikkagi enesekindel vastus ja see on "
133 "vale. Vaata registrit enne, kui määrangut usud."),
134 ("Ootab tänavavaate kaadrit",
135 "Treeningpildid on GeoGuessri Street View kaadrid: väljas, päevavalguses, "
136 "tee tasandilt. Toapildid, lähivõtted, öised kaadrid ja ekraanitõmmised "
137 "muust kontekstist jäävad ootuspäraselt kehvaks."),
138 ("Naaberriigid lähevad segamini",
139 "Ida-Euroopa on nõrgim koht. Läti, Slovakkia ja Ukraina tabavus on "
140 "testihulgal null: need lähevad segamini Venemaa, Poola ja üksteisega."),
141 ("Klassid ei ole ühesuurused",
142 "Testipilte on riigi kohta 11 kuni 200. Väikese valimiga riigi protsent on "
143 "lärmakas, mistõttu on registris iga rea juures ka valimi suurus."),
144 ("Koordinaadid on teadlikult väljas",
145 "Laius- ja pikkuskraadi määramine nõuab miljoneid pilte ja arvutusvõimsust, "
146 "mida sellel projektil ei ole. Riigi tasand on aus ülesanne, mille sai "
147 "korralikult lahendada. Piirkonna tasand on järgmine samm."),
148 ]
149
150
151 def _caveats_html():
152 items = "".join(
153 f"""
154 <li class="note">
155 <span class="note__n num">{n:02d}</span>
156 <div>
157 <h3>{title}</h3>
158 <p>{body}</p>
159 </div>
160 </li>
161 """
162 for n, (title, body) in enumerate(CAVEATS, start=1)
163 )
164 return f"""
165 <section class="sec">
166 <h2 class="sec__h"><span>Mida peaks teadma</span></h2>
167 <ol class="notes">{items}</ol>
168 </section>
169 """
170
171
172 def _index_html():
173 if PER_COUNTRY.empty:
174 return ""
175
176 rows = "".join(
177 f"""
178 <li class="gz__row">
179 <span class="gz__name">{row.country}</span>
180 <span class="gz__dots" aria-hidden="true"></span>
181 <span class="gz__pct num">{_pct(row.accuracy, 0)}</span>
182 <span class="gz__n num">{int(row.support)}</span>
183 </li>
184 """
185 for row in PER_COUNTRY.itertuples()
186 )
187 return f"""
188 <section class="sec">
189 <h2 class="sec__h"><span>Register</span><b class="num">{len(PER_COUNTRY)} riiki</b></h2>
190 <p class="sec__note">
191 Toorandmestikus oli 124 riiki. Puhastus viskas välja rikutud, liiga väikesed,
192 tumedad, heledad ja udused pildid ning tajuräsi järgi korduvad kaadrid.
193 Riigid, millele jäi alla 100 pildi, langesid välja tervikuna. Alles jäi
194 {len(PER_COUNTRY)}. Järjestatud tabavuse, mitte tähestiku järgi. Teine veerg
195 on tabavus, kolmas testipiltide arv.
196 </p>
197 <ul class="gz">{rows}</ul>
198 </section>
199 """
200
201
202 FOOTER = """
203 <footer class="foot">
204 <p>
205 Kahefaasiline treening: kõigepealt lineaarne proovipea külmutatud põhivõrgu
206 peal, et juhuslikult lähtestatud pea gradiendid eeltreenitud kaale ära ei
207 lõhuks, seejärel neli viimast transformeri plokki lahti madalama
208 õppekiirusega. Augmentatsioonis ei ole horisontaalpeegeldust: kummal pool
209 teed sõidetakse, on päris geograafiline vihje ja peegeldus rikub selle ära.
210 </p>
211 </footer>
212 """
213
214
215 # ── Kujundus ──────────────────────────────────────────────────────────────────
216
217 CSS = """
218 @import url('https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,300;0,400;0,500;0,600;1,300;1,400&family=Archivo+Narrow:wght@400;500;600&display=swap');
219
220 :root {
221 --paper: #f5f1e7;
222 --ink: #262019;
223 --soft: #625849;
224 --faint: #9c9075;
225 --rule: #d9cfb8;
226 --rule-soft: #e6ddc9;
227 --mark: #9c4a2a;
228 --sea: #2f6273;
229 }
230
231 gradio-app, .gradio-container, body {
232 background: var(--paper) !important;
233 color: var(--ink) !important;
234 font-family: 'Spectral', Georgia, serif !important;
235 }
236
237 .gradio-container {
238 max-width: 1000px !important;
239 margin: 0 auto !important;
240 padding: 0 34px 90px !important;
241 }
242
243 footer.built-with, .built-with, footer svg { display: none !important; }
244 ::selection { background: var(--mark); color: var(--paper); }
245
246 /* Kaardisilt: kitsas grotesk versaalis, hõredalt. Kõik, mida silm otsib. */
247 .eyebrow {
248 font-family: 'Archivo Narrow', sans-serif;
249 font-size: 11.5px;
250 font-weight: 600;
251 letter-spacing: 0.17em;
252 text-transform: uppercase;
253 color: var(--faint);
254 margin: 0;
255 }
256 .eyebrow--mark { color: var(--mark); }
257
258 /* Arvud kitsas grotesk, tabulaarselt, et veerud ei triiviks. */
259 .num {
260 font-family: 'Archivo Narrow', sans-serif;
261 font-variant-numeric: tabular-nums;
262 font-weight: 500;
263 }
264
265 /* ── Päis ─────────────────────────────────────────────────────────────── */
266
267 .mast { position: relative; padding: 66px 0 0; }
268
269 /* Koordinaadivõrk: peenjooned iga 32 px, iga neljas veidi tugevam. Ainult
270 päise all, sest see on kaardi element, mitte tapeet. */
271 .mast__grid {
272 position: absolute;
273 inset: 0 -34px auto;
274 height: 260px;
275 background-image:
276 linear-gradient(var(--rule-soft) 1px, transparent 1px),
277 linear-gradient(90deg, var(--rule-soft) 1px, transparent 1px),
278 linear-gradient(var(--rule) 1px, transparent 1px),
279 linear-gradient(90deg, var(--rule) 1px, transparent 1px);
280 background-size: 32px 32px, 32px 32px, 128px 128px, 128px 128px;
281 opacity: 0.5;
282 -webkit-mask-image: linear-gradient(#000, transparent 88%);
283 mask-image: linear-gradient(#000, transparent 88%);
284 pointer-events: none;
285 }
286 .mast__body { position: relative; }
287
288 .mast__title {
289 font-family: 'Spectral', Georgia, serif;
290 font-size: clamp(46px, 7vw, 68px);
291 font-weight: 400;
292 line-height: 1;
293 letter-spacing: -0.015em;
294 margin: 20px 0 20px;
295 color: var(--ink);
296 }
297 .mast__lede {
298 max-width: 56ch;
299 font-size: 17px;
300 font-weight: 300;
301 line-height: 1.62;
302 color: var(--soft);
303 margin: 0 0 40px;
304 }
305 .mast__lede em { color: var(--ink); font-style: italic; }
306
307 .stats {
308 position: relative;
309 display: flex;
310 flex-wrap: wrap;
311 border-top: 1px solid var(--rule);
312 border-bottom: 1px solid var(--rule);
313 }
314 .stat { flex: 1 1 124px; padding: 15px 0 14px; }
315 .stat__v { font-size: 21px; margin: 7px 0 0; color: var(--ink); }
316
317 /* ── Määrang ──────────────────────────────────────────────────────────── */
318
319 .stage { margin-top: 38px !important; gap: 32px !important; }
320 .stage .block {
321 background: transparent !important;
322 border: 1px solid var(--rule) !important;
323 border-radius: 0 !important;
324 box-shadow: none !important;
325 }
326 .stage button { border-radius: 0 !important; font-family: 'Archivo Narrow', sans-serif !important; }
327
328 .empty {
329 font-family: 'Archivo Narrow', sans-serif;
330 font-size: 13px;
331 letter-spacing: 0.05em;
332 color: var(--faint);
333 margin: 4px 0 0;
334 }
335
336 .out__lead {
337 font-size: clamp(34px, 5vw, 44px);
338 font-weight: 400;
339 line-height: 1.05;
340 letter-spacing: -0.012em;
341 margin: 12px 0 8px;
342 color: var(--ink);
343 }
344 .out__meta {
345 font-size: 15px;
346 font-weight: 300;
347 color: var(--soft);
348 margin: 0 0 22px;
349 }
350 .out__meta em { font-style: italic; }
351
352 .alts { list-style: none; margin: 0; padding: 0; }
353 .alt {
354 display: grid;
355 grid-template-columns: auto 1fr 52px;
356 align-items: center;
357 gap: 0 14px;
358 padding: 8px 0;
359 border-top: 1px solid var(--rule-soft);
360 }
361 .alt__name { font-size: 15px; font-weight: 300; color: var(--soft); white-space: nowrap; }
362 .alt__bar { display: block; height: 2px; background: var(--rule-soft); }
363 .alt__bar i { display: block; height: 100%; background: var(--sea); }
364 .alt__pct { font-size: 13.5px; color: var(--soft); text-align: right; }
365
366 /* ── Sektsioonid ──────────────────────────────────────────────────────── */
367
368 .sec { margin-top: 76px; }
369
370 /* Legendi pealkiri: versaal, mille kõrvale jookseb joon üle laiuse. */
371 .sec__h {
372 display: flex;
373 align-items: center;
374 gap: 16px;
375 margin: 0 0 24px;
376 font-family: 'Archivo Narrow', sans-serif;
377 font-size: 12.5px;
378 font-weight: 600;
379 letter-spacing: 0.17em;
380 text-transform: uppercase;
381 color: var(--ink);
382 }
383 .sec__h::after {
384 content: '';
385 flex: 1;
386 height: 1px;
387 background: var(--rule);
388 }
389 .sec__h b {
390 order: 3;
391 font-size: 12px;
392 font-weight: 500;
393 letter-spacing: 0.06em;
394 text-transform: none;
395 color: var(--mark);
396 }
397 .sec__note {
398 max-width: 74ch;
399 font-size: 15.5px;
400 font-weight: 300;
401 line-height: 1.68;
402 color: var(--soft);
403 margin: 0 0 30px;
404 }
405
406 .notes { list-style: none; margin: 0; padding: 0; }
407 .note {
408 display: grid;
409 grid-template-columns: 48px 1fr;
410 gap: 0 16px;
411 padding: 19px 0;
412 border-top: 1px solid var(--rule-soft);
413 }
414 .note:first-child { border-top: 0; padding-top: 0; }
415 .note__n { font-size: 12.5px; color: var(--mark); padding-top: 5px; }
416 .note h3 {
417 font-size: 16.5px;
418 font-weight: 500;
419 letter-spacing: -0.005em;
420 margin: 0 0 7px;
421 color: var(--ink);
422 }
423 .note p {
424 max-width: 68ch;
425 font-size: 15.5px;
426 font-weight: 300;
427 line-height: 1.68;
428 color: var(--soft);
429 margin: 0;
430 }
431
432 /* ── Register ─────────────────────────────────────────────────────────── */
433
434 .gz {
435 list-style: none;
436 margin: 0;
437 padding: 0;
438 display: grid;
439 grid-template-columns: 1fr 1fr;
440 gap: 0 52px;
441 }
442 .gz__row {
443 display: grid;
444 grid-template-columns: auto 1fr auto auto;
445 align-items: baseline;
446 gap: 0 8px;
447 padding: 6px 0;
448 }
449 .gz__name { font-size: 15.5px; font-weight: 300; color: var(--ink); white-space: nowrap; }
450 /* Juhtpunktid, nagu atlase registris: seovad nime numbriga üle tühja ruumi. */
451 .gz__dots {
452 border-bottom: 1px dotted var(--rule);
453 transform: translateY(-4px);
454 min-width: 14px;
455 }
456 .gz__pct { font-size: 13.5px; color: var(--soft); width: 42px; text-align: right; }
457 .gz__n { font-size: 12.5px; color: var(--faint); width: 34px; text-align: right; }
458
459 /* ── Jalus ────────────────────────────────────────────────────────────── */
460
461 .foot { margin-top: 68px; padding-top: 22px; border-top: 1px solid var(--rule); }
462 .foot p {
463 max-width: 78ch;
464 font-size: 14.5px;
465 font-weight: 300;
466 line-height: 1.72;
467 color: var(--faint);
468 margin: 0;
469 }
470
471 @media (max-width: 760px) {
472 .gradio-container { padding: 0 20px 58px !important; }
473 .mast { padding-top: 42px; }
474 .mast__grid { inset: 0 -20px auto; }
475 .stat { flex-basis: 50%; }
476 .gz { grid-template-columns: 1fr; gap: 0; }
477 .note { grid-template-columns: 34px 1fr; }
478 }
479 """
480
481 THEME = gr.themes.Base(
482 primary_hue=gr.themes.colors.orange,
483 neutral_hue=gr.themes.colors.stone,
484 font=gr.themes.GoogleFont("Spectral"),
485 font_mono=gr.themes.GoogleFont("Archivo Narrow"),
486 ).set(
487 body_background_fill="#f5f1e7",
488 background_fill_primary="#f5f1e7",
489 background_fill_secondary="#efe9db",
490 border_color_primary="#d9cfb8",
491 body_text_color="#262019",
492 body_text_color_subdued="#625849",
493 button_primary_background_fill="#9c4a2a",
494 button_primary_text_color="#f5f1e7",
495 block_radius="0px",
496 button_large_radius="0px",
497 button_small_radius="0px",
498 input_radius="0px",
499 block_shadow="none",
500 )
501
502 with gr.Blocks(title="CountrySense", analytics_enabled=False) as demo:
503 gr.HTML(_head_html())
504
505 with gr.Row(elem_classes="stage", equal_height=False):
506 with gr.Column(scale=5):
507 image = gr.Image(type="pil", height=300, show_label=False,
508 sources=["upload", "clipboard"])
509 with gr.Column(scale=6):
510 result = gr.HTML(EMPTY)
511
512 image.change(predict, inputs=image, outputs=result)
513
514 gr.HTML(_caveats_html())
515 gr.HTML(_index_html())
516 gr.HTML(FOOTER)
517
518
519 if __name__ == "__main__":
520 # Üks ennustus korraga. Mudel võtab protsessorimälust ligi gigabaidi ja
521 # paralleelsed päringud kahekordistaksid selle ilma midagi juurde andmata.
522 demo.queue(default_concurrency_limit=1, max_size=16)
523
524 # Gradio 6: theme ja css käivad launch()-i, mitte Blocks()-i.
525 demo.launch(
526 theme=THEME,
527 css=CSS,
528 # Vaikimisi jääb kohalik käivitus ainult sellesse masinasse. Konteiner
529 # seab COUNTRYSENSE_HOST=0.0.0.0, sest pöördproksi jõuab kohale ainult
530 # üle konteinerivõrgu.
531 server_name=os.environ.get("COUNTRYSENSE_HOST", "127.0.0.1"),
532 server_port=int(os.environ.get("PORT", "7860")),
533 # Demo võtab vastu tänavapilte, mitte suvalisi faile.
534 max_file_size="12mb",
535 )
536