profileShare

rasmusjy / countrysense

Read-only snapshot

No repository description.

main default branch 19 files Expires Sep 13, 2026, 9:06 AM

Commit

Add the container build and deploy script

The deploy target is not in the repository: this code is shared read-only with
people outside the project, and the server address and login are not theirs to
have. Set VPS=user@host in your shell before running deploy.sh.
commit 3c54490

4 changed files with +658 and −11

Jump to a changed file
  1. Dockerfile +43 −0
  2. app.py +511 −11
  3. deploy.sh +70 −0
  4. docker-compose.yml +34 −0
added Dockerfile +43 −0
@@ -0,0 +1,43 @@
1 +# syntax=docker/dockerfile:1
2 +
3 +# CountrySense demo: CLIP ViT-B/16 klassifikaator protsessoril.
4 +#
5 +# Torch tuleb CPU-indeksist. Vaikimisi PyPI ratas veaks kaasa CUDA teegid, mis
6 +# on paar gigabaiti kettal ja millest sellel serveril kasu ei ole.
7 +
8 +FROM python:3.12-slim
9 +
10 +WORKDIR /app
11 +ENV PYTHONUNBUFFERED=1
12 +ENV PIP_NO_CACHE_DIR=1
13 +ENV PORT=3000
14 +ENV COUNTRYSENSE_HOST=0.0.0.0
15 +ENV COUNTRYSENSE_CKPT=runs/clip_vit_b16/best.pt
16 +# Mudelikaal on checkpointis endas (pretrained=False), seega ei lae konteiner
17 +# käivitumisel midagi alla. See seab kodukausta kirjutatavaks igaks juhuks.
18 +ENV HF_HOME=/tmp/hf
19 +ENV MPLCONFIGDIR=/tmp/mpl
20 +
21 +RUN pip install --index-url https://download.pytorch.org/whl/cpu \
22 + torch==2.13.0 torchvision==0.28.0 \
23 + && pip install \
24 + timm==1.0.28 \
25 + pandas==2.2.3 \
26 + numpy==2.2.6 \
27 + pillow==11.3.0 \
28 + "gradio>=6,<7"
29 +
30 +COPY countrysense ./countrysense
31 +COPY app.py ./
32 +COPY runs/clip_vit_b16/best.pt ./runs/clip_vit_b16/best.pt
33 +COPY runs/clip_vit_b16/metrics.json runs/clip_vit_b16/per_country.csv ./runs/clip_vit_b16/
34 +
35 +RUN useradd -m -u 1001 demo && chown -R demo:demo /app
36 +USER demo
37 +
38 +EXPOSE 3000
39 +
40 +HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=3 \
41 + CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:3000/', timeout=8).status == 200 else 1)"
42 +
43 +CMD ["python", "app.py"]
modified app.py +511 −11
@@ -1,35 +1,535 @@
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
1 12 import os
13 +from pathlib import Path
2 14
3 15 import gradio as gr
16 +import pandas as pd
4 17 import timm
5 18 import torch
6 19
7 20 from countrysense.data import build_transforms
8 21 from countrysense.model import load_checkpoint
9 22
10 -CHECKPOINT = os.environ.get("COUNTRYSENSE_CKPT", "runs/clip_vit_b16/best.pt")
23 +CHECKPOINT = Path(os.environ.get("COUNTRYSENSE_CKPT", "runs/clip_vit_b16/best.pt"))
24 +RUN_DIR = CHECKPOINT.parent
11 25
12 26 device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
13 -model, classes = load_checkpoint(CHECKPOINT, device)
27 +model, classes = load_checkpoint(str(CHECKPOINT), device)
14 28 data_cfg = timm.data.resolve_model_data_config(model.backbone)
15 29 tfm = build_transforms(data_cfg["mean"], data_cfg["std"],
16 30 data_cfg["input_size"][-1], train=False)
17 31
18 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 +
19 61 def predict(img):
62 + if img is None:
63 + return EMPTY
64 +
20 65 with torch.no_grad():
21 66 probs = model(tfm(img.convert("RGB")).unsqueeze(0).to(device)).softmax(1)[0]
22 - return {classes[i]: float(p) for p, i in
23 - zip(*[t.tolist() for t in probs.topk(min(5, len(classes)))])}
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 ──────────────────────────────────────────────────────────────────
24 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');
25 219
26 -demo = gr.Interface(
27 - fn=predict,
28 - inputs=gr.Image(type="pil"),
29 - outputs=gr.Label(num_top_classes=5),
30 - title="CountrySense",
31 - description="Ennustab tänavapildi järgi riigi. Riigi tase, mitte koordinaadid.",
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",
32 500 )
33 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 +
34 519 if __name__ == "__main__":
35 - demo.launch()
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 + )
added deploy.sh +70 −0
@@ -0,0 +1,70 @@
1 +#!/usr/bin/env bash
2 +# One-command redeploy for countrysense.rasmusj.com.
3 +#
4 +# Run from Git Bash on Windows: ./deploy.sh
5 +#
6 +# The checkpoint is 330 MB and almost never changes, so it travels on its own
7 +# and only when its hash differs from the copy already on the server. Everything
8 +# else is a few hundred kilobytes of source.
9 +#
10 +# The shared reverse proxy at /opt/caddy is never touched.
11 +set -euo pipefail
12 +
13 +# Deploy target. Kept out of the repository on purpose: this code is shared
14 +# read-only with people outside the project, and the server address and
15 +# login are not theirs to have. Set it once in your shell:
16 +#
17 +# export VPS=user@host
18 +#
19 +VPS=${VPS:?set VPS=user@host before deploying}
20 +DEST=/opt/projects/countrysense
21 +CKPT=runs/clip_vit_b16/best.pt
22 +
23 +cd "$(dirname "$0")"
24 +
25 +if [ ! -f "$CKPT" ]; then
26 + echo " $CKPT is missing. Train first, or copy it in from the run you kept."
27 + exit 1
28 +fi
29 +
30 +echo "==> Ensuring VPS project dir exists..."
31 +ssh "$VPS" "mkdir -p '$DEST/model'"
32 +
33 +echo "==> Comparing the checkpoint with the one on the server..."
34 +local_sum=$(sha256sum "$CKPT" | cut -d' ' -f1)
35 +remote_sum=$(ssh "$VPS" "sha256sum '$DEST/model/best.pt' 2>/dev/null | cut -d' ' -f1" || true)
36 +if [ "$local_sum" != "$remote_sum" ]; then
37 + echo " uploading 330 MB, this takes a few minutes..."
38 + scp "$CKPT" "$VPS:$DEST/model/best.pt"
39 +else
40 + echo " unchanged, skipping the upload"
41 +fi
42 +
43 +echo "==> Uploading source (the checkpoint, runs output and notebooks stay out)..."
44 +tar --exclude=.git --exclude=__pycache__ --exclude=notebooks --exclude='*.pt' \
45 + --exclude='*.png' --exclude=data -czf - . | ssh "$VPS" "
46 + rm -rf '$DEST/src.tmp' &&
47 + mkdir -p '$DEST/src.tmp' &&
48 + tar -C '$DEST/src.tmp' -xzf - &&
49 + mkdir -p '$DEST/src.tmp/runs/clip_vit_b16' &&
50 + cp '$DEST/model/best.pt' '$DEST/src.tmp/runs/clip_vit_b16/best.pt' &&
51 + rm -rf '$DEST/app' &&
52 + mv '$DEST/src.tmp' '$DEST/app'
53 +"
54 +
55 +echo "==> Building and restarting the container..."
56 +ssh "$VPS" "cd '$DEST/app' && docker compose up -d --build"
57 +
58 +echo "==> Waiting for the health check to go green (first load reads 330 MB)..."
59 +ssh "$VPS" "
60 + for i in \$(seq 1 40); do
61 + state=\$(docker inspect -f '{{.State.Health.Status}}' countrysense 2>/dev/null || echo starting)
62 + [ \"\$state\" = healthy ] && echo ' healthy' && exit 0
63 + sleep 5
64 + done
65 + echo ' still not healthy, check: docker logs countrysense'
66 + exit 1
67 +"
68 +
69 +echo ""
70 +echo "==> Done. Live at https://countrysense.rasmusj.com"
added docker-compose.yml +34 −0
@@ -0,0 +1,34 @@
1 +# countrysense.rasmusj.com
2 +#
3 +# Caddy already runs on the external `web` network and terminates TLS, so
4 +# nothing is published to the host and the domain lives in /opt/caddy/Caddyfile.
5 +#
6 +# The memory limit is the point of this file. The box has under 2 GB free and no
7 +# swap, and a torch process that grows past its share would take the neighbours
8 +# with it. With a cap, an overrun kills this container alone and Docker restarts
9 +# it.
10 +
11 +name: countrysense
12 +
13 +services:
14 + countrysense:
15 + build: .
16 + container_name: countrysense
17 + restart: unless-stopped
18 + environment:
19 + PORT: '3000'
20 + mem_limit: 1200m
21 + memswap_limit: 1200m
22 + networks:
23 + - web
24 + expose:
25 + - '3000'
26 + logging:
27 + driver: json-file
28 + options:
29 + max-size: '10m'
30 + max-file: '3'
31 +
32 +networks:
33 + web:
34 + external: true