Od panorama do virtuelne ture – bez programiranja
Zahvaljujući ovom jednostavnom Python programu, uspeli smo da od samo četiri panoramske fotografije napravimo interaktivnu virtuelnu turu – potpuno bez ikakvog predznanja iz programiranja.
Program omogućava da:
- učitate više panorama (fotografija od 360°),
- postavite hotspotove klikom na željene tačke na slici,
- povežete panorame tako da korisnik može “šetati” iz jedne scene u drugu,
- podesite ugao gledanja, zoom, i granice kretanja,
- a zatim sve to izvezete kao jedan HTML fajl koji se može otvoriti u bilo kom veb pregledaču – bez dodatnog softvera.
Ono što smo napravili nije video u klasičnom smislu, već interaktivna 360° tura – slična onima koje se koriste u arhitekturi, turizmu ili nekretninama. Korisnik može da se okreće levo/desno, pogleda gore/dole, i klikom prelazi u sledeću prostoriju.
Sve ovo postignuto je uz pomoć jednostavnog korisničkog interfejsa, bez pisanja ijedne linije koda. Primer pogledajte ispod. Ovo je snimak procedure za pravljenje virtuelne ture:
https://www.abel.rs/programi/vti.-2025-07-16_17.40.07.mp4
Ovo je adresa gotove virtuelne ture (napomena: jpg fajlovi su veliki svako oko 16Mb tako da je učitavanje sporo):
https://www.abel.rs/programi/u-ime-naroda/index.html
Naš zajednički projekat koji spaja jednostavnost sa moći profesionalnog alata.
Zajedno smo razvili desktop aplikaciju u Pythonu koja omogućava svakome – bez ikakvog iskustva u programiranju – da od svojih JPG panoramskih slika napravi kompletnu virtuelnu turu sa hotspotovima, u formatu spremnom za prikaz u bilo kom modernom veb pregledaču.
💡 Šta smo napravili?
✅ Intuitivan grafički interfejs (Tkinter)
✅ Učitavanje i pregled više panorama iz foldera
✅ Postavljanje početne scene jednim klikom
✅ Dodavanje hotspotova klikom direktno na sliku
✅ Opis svakog hotspot-a (naslov, cilj, tekstualni opis)
✅ Podesivi parametri pogleda (hfov
, yaw
, pitch
, vaov
, itd.)
✅ Automatsko generisanje Pannellum HTML fajla
✅ Kompatibilnost sa horizontalnim i delimičnim panoramama (putem vaov
parametra)
✅ Potpuno lokalno, bez interneta, bez zavisnosti od cloud-a
Kako funkcioniše?
- Otvorite folder sa panoramama (JPG)
- Izaberite panoramu i kliknite “Postavi kao trenutnu”
- Kliknite mišem na sliku da dodate hotspot – izaberite ciljnu scenu i upišite opis
- Podesite početne parametre pogleda
- Klikom na “Generiši HTML turu”, automatski dobijate fajl
tour.html
Otvorite tour.html
u pretraživaču – i imate kompletnu interaktivnu turu! 🔄
Zašto je važno to što smo napravili?
Zato što smo demokratizovali izradu virtuelnih panorama. Bez skupih softvera, bez potrebe za pisanjem koda ili poznavanjem JavaScript biblioteka. Naš alat omogućava svakome da svoje prostore predstavi na moderan, interaktivan i profesionalan način – u par klikova.
Zajednički smo stvorili alat koji može koristiti bilo ko:
📸 Fotograf, 🏛 Muzejski kustos, 🧭 Turistički vodič, 🧑🏫 Edukator, 🧱 Arhitekta, 🎓 Student…
Šta dobijate?
- Python aplikaciju koju možete pokrenuti lokalno (Windows/Linux)
- Praktičan GUI bez komandi iz terminala
- Potpuno generisanu HTML turu (koju možete postaviti na sajt ili podeliti)
Zaključak
Zajedno smo napravili alat koji spaja tehničku snagu i korisničku jednostavnost.
Ovo je primer kako saradnja, kreativnost i praktične potrebe mogu da stvore rešenje koje olakšava živote ljudima iz raznih oblasti.
Opis programa – PanoramaTourEditor
PanoramaTourEditor je desktop aplikacija u Pythonu koja korisniku omogućava da učita više 360° panoramskih slika (u JPG formatu), da ih međusobno poveže putem hotspotova, podesi parametre kamere, i na kraju generiše kompletan HTML fajl za prikaz interaktivne virtuelne ture pomoću Pannellum biblioteke.
Funkcionalnosti programa:
1. Učitavanje slika
- Korisnik bira folder koji sadrži panoramske slike (
.jpg
). - Sve slike se automatski prikazuju u listbox-u sa desne strane.
- Klikom na sliku iz liste može se postaviti aktivna panorama za prikaz.
2. Prikaz panorame
- Izabrana panorama se prikazuje u glavnom prikazu (canvas).
- Slika se skalira tako da odgovara visini prozora.
3. Dodavanje hotspotova klikom
- Klikom na sliku korisnik postavlja hotspot.
- Automatski se izračunavaju
yaw
ipitch
u odnosu na kliknute koordinate. - Pojavljuje se dijalog za unos opisa hotspot-a.
- Hotspot se povezuje sa drugom scenom iz liste.
4. Podešavanje parametara scene
- Unosi se:
hfov
: horizontalno vidno poljeyaw
: horizontalna orijentacija kamerepitch
: vertikalni ugaominPitch
imaxPitch
: granice vertikalnog gledanjaminHfov
imaxHfov
: granice zoom nivoavaov
: vertikalni ugao vidnog polja, koristi se kod horizontalno snimanih panorama (nije deo zvanične Pannellum specifikacije, ali se koristi lokalno u okviru aplikacije za korekciju proporcija)
5. Pregled slike
- Klikom na sliku iz liste prikazuje se thumbnail preview slike.
6. Generisanje HTML ture
- Svi uneseni parametri i povezane scene se pretvaraju u JSON konfiguraciju za Pannellum.
- Hotspotovi se automatski uvrštavaju u svaku scenu.
- HTML fajl (
tour.html
) se snima u isti folder gde se nalaze slike.
Rezultat
Kao rezultat, korisnik dobija potpuno funkcionalan HTML fajl koji prikazuje panorame kroz moderni web prikaz, sa mogućnošću navigacije između njih klikom na hotspotove. Ovaj fajl je spreman za objavu na veb-sajtu ili za lokalnu prezentaciju.
Tehnologije korišćene u programu:
- Python 3
- Tkinter – za grafički interfejs
- PIL / Pillow – za obradu i prikaz slika
- Pannellum (CDN link) – za renderovanje panorama u browseru
Ključna vrednost programa
- Korisniku nudi brz, jednostavan i vizuelan način da poveže više panorama u interaktivnu turu.
- Ne zahteva dodatne instalacije na strani korisnika koji gleda turu – sve radi u browseru.
- Uklanja potrebu za ručnim pisanjem JSON konfiguracija i kodiranjem.
Detaljna matematika iza PanoramaTourEditor-a
Program koristi sfernu geometriju i projekciju panorama na cilindar/sferu da bi omogućio tačno pozicioniranje tačaka pogleda (yaw
, pitch
) i podešavanje ugla prikaza (hfov
, vaov
).
1. Pretvaranje kliknutih koordinata u sferne uglove
Korisnik klikne na poziciju \( (x, y) \) na slici dimenzija \( W \times H \). Potrebno je transformisati tu tačku u uglove na virtuelnoj sferi.
Normalizacija koordinata:
\[ x_n = \frac{x}{W}, \quad y_n = \frac{y}{H} \]
Proračun horizontalnog ugla (yaw):
\[ \text{yaw} = x_n \cdot 360^\circ – 180^\circ \]
Proračun vertikalnog ugla (pitch):
\[ \text{pitch} = 90^\circ – y_n \cdot 180^\circ \]
Primer: centar slike \((x_n=0.5, y_n=0.5) \Rightarrow \text{yaw} = 0^\circ, \text{pitch} = 0^\circ\)
2. Konverzija u kartezijanske koordinate (opciono)
Ako želimo dodatne transformacije, možemo pretvoriti sferne koordinate u 3D sistem:
\[ \theta = \text{yaw} \cdot \frac{\pi}{180}, \quad \phi = \left(90^\circ – \text{pitch}\right) \cdot \frac{\pi}{180} \]
\[ x = \sin(\phi) \cdot \cos(\theta), \quad y = \cos(\phi), \quad z = \sin(\phi) \cdot \sin(\theta) \]
3. Horizontalno vidno polje (HFOV)
HFOV predstavlja horizontalni ugao prikaza. Veći HFOV znači širi kadar, manji znači uvećani prikaz:
\[ \text{vidljivo u pikselima} = \frac{\text{HFOV}}{360^\circ} \cdot W \]
4. Vertikalni ugao prikaza (VAOV)
VAOV je “vertikalni field of view”, koristi se kod horizontalnih panorama koje ne pokrivaju punih 180°:
\[ \text{VAOV} = \text{Vertikalni ugao prikaza} \]
\[ \text{visina prikaza} = \frac{\text{VAOV}}{180^\circ} \cdot H \]
Na primer, VAOV od 70° prikazuje oko 39% vertikalnog opsega slike.
5. Granice kretanja pogleda
Granice pitch i hfov definišu koliko korisnik može da gleda gore/dole i koliko da zumira:
\[ \text{pitch}_{\min} \leq \text{pitch} \leq \text{pitch}_{\max} \]
\[ \text{hfov}_{\min} \leq \text{hfov} \leq \text{hfov}_{\max} \]
6. Skaliranje prikaza slike u GUI
Program automatski prilagođava veličinu slike kako bi stala u prozor:
\[ s = \frac{H_{\text{prozor}} – \text{margin}}{H_{\text{slika}}} \]
\[ W’ = s \cdot W, \quad H’ = s \cdot H \]
7. Kreiranje hotspotova
Na osnovu kliknute pozicije i izabranog cilja, formira se JSON objekt sa sfernim koordinatama:
{
"yaw": 45.0,
"pitch": -10.5,
"type": "scene",
"text": "Idi u sledeću sobu",
"sceneId": "slika2.jpg"
}
8. Pregled svih formula
- \( x_n = \frac{x}{W} \), \( y_n = \frac{y}{H} \)
- \( \text{yaw} = x_n \cdot 360^\circ – 180^\circ \)
- \( \text{pitch} = 90^\circ – y_n \cdot 180^\circ \)
- \( \theta = \text{yaw} \cdot \frac{\pi}{180} \), \( \phi = (90^\circ – \text{pitch}) \cdot \frac{\pi}{180} \)
- \( x = \sin(\phi)\cos(\theta) \), \( y = \cos(\phi) \), \( z = \sin(\phi)\sin(\theta) \)
- \( \text{vidljivo}_\text{HFOV} = \frac{\text{HFOV}}{360^\circ} \cdot W \)
- \( \text{vidljivo}_\text{VAOV} = \frac{\text{VAOV}}{180^\circ} \cdot H \)
- \( s = \frac{H_{\text{prozor}}}{H_{\text{slika}}} \)
Umesto zaključka
PanoramaTourEditor koristi preciznu geometrijsku i trigonometrijsku transformaciju kako bi omogućio tačno pozicioniranje pogleda i hotspotova unutar 360° prostora. Sve koordinate su pažljivo izračunate i pretvorene iz kliknutih tačaka u sferni prikaz koji koristi Pannellum.

Programski kod:
# The MIT License (MIT) # Copyright (c) 2025 Aleksandar Maričić # # Ovim se omogućava bilo kome da koristi, kopira, menja, spaja, objavljuje, # distribuira, daje podlicencu i/ili prodaje kopije ovog softverskog programa, # uz uslov da u svim kopijama ili značajnim delovima softverskog programa bude # uključena sledeća obavest: # # Copyright (c) 2025 Aleksandar Maričić # # Ovaj softverski program je pružen "takav kakav jeste", bez bilo kakvih garancija, # izričitih ili impliciranih, uključujući, ali ne ograničavajući se na, garancije o # prikladnosti za prodaju ili pogodnosti za određenu svrhu. U svakom slučaju, autori # ili nosioci prava nisu odgovorni za bilo kakvu štetu ili druge obaveze koje mogu nastati # usled upotrebe ovog softverskog programa. # Napravi virtuelnu turu sam — interaktivno, jednostavno, bez programiranja! import os import tkinter as tk from tkinter import filedialog, messagebox, simpledialog from PIL import Image, ImageTk class PanoramaTourEditor(tk.Tk): def __init__(self): super().__init__() self.title("Pannellum Tour Editor") self.attributes('-zoomed', True) self.folder = None self.images = [] self.current_pano = None self.hotspots = {} self.scene_params = {} self.defaults = { "hfov": 110, "yaw": 0, "pitch": 0, "minPitch": -35, "maxPitch": 35, "minHfov": 50, "maxHfov": 130, "vaov": 70.0 } self.param_info = { "hfov": "HFOV (Horizontal Field of View): određuje koliko široko korisnik vidi. Veća vrednost prikazuje širu scenu, manja uvećava prikaz (zoom).", "yaw": "Yaw: horizontalna orijentacija pogleda na početku (0 = centar slike, -90 levo, +90 desno).", "pitch": "Pitch: vertikalni nagib pogleda na početku (0 = sredina, -30 = dole, +30 = gore).", "minPitch": "Minimalni pitch: koliko korisnik može gledati na dole (negativne vrednosti).", "maxPitch": "Maksimalni pitch: koliko korisnik može gledati na gore (pozitivne vrednosti).", "minHfov": "Minimalni HFOV: maksimalno zumiranje koje je dozvoljeno (manje vrednosti = veći zum).", "maxHfov": "Maksimalni HFOV: koliko korisnik može da 'odzumira' (veće vrednosti = širi prikaz).", "vaov": "VAOV (Vertical Angle of View): korisno za 360° panorame koje ne pokrivaju punu visinu. Postavlja vertikalni domet pogleda (npr. 70)." } self.create_widgets() def create_widgets(self): main_frame = tk.Frame(self) main_frame.pack(fill=tk.BOTH, expand=True) self.canvas = tk.Canvas(main_frame, bg="black") self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) self.h_scroll = tk.Scrollbar(main_frame, orient=tk.HORIZONTAL, command=self.canvas.xview) self.h_scroll.pack(side=tk.BOTTOM, fill=tk.X) self.canvas.config(xscrollcommand=self.h_scroll.set) self.pano_image_container = self.canvas.create_image(0, 0, anchor=tk.NW) self.canvas.bind("<Button-1>", self.on_pano_click) self.right_frame = tk.Frame(main_frame, width=350) self.right_frame.pack(side=tk.RIGHT, fill=tk.Y) self.btn_open_folder = tk.Button(self.right_frame, text="Otvori folder", font=("Arial", 12), command=self.open_folder) self.btn_open_folder.pack(pady=10, fill=tk.X) self.lst_images = tk.Listbox(self.right_frame, width=40, height=10) self.lst_images.pack(padx=10) self.lst_images.bind("<<ListboxSelect>>", self.show_selected_image_preview) self.preview_label = tk.Label(self.right_frame, text="Preview", bd=2, relief=tk.SUNKEN) self.preview_label.pack(padx=10, pady=10) self.btn_set_pano = tk.Button(self.right_frame, text="Postavi kao trenutnu panoramu", font=("Arial", 12), command=self.set_current_pano) self.btn_set_pano.pack(pady=10, fill=tk.X) params_frame = tk.LabelFrame(self.right_frame, text="Početni parametri scene", font=("Arial", 11, "bold")) params_frame.pack(padx=10, pady=10, fill=tk.X) self.entries = {} def add_param(label, default): frame = tk.Frame(params_frame) frame.pack(fill=tk.X, pady=2) tk.Label(frame, text=label + ":", width=10, anchor="w").pack(side=tk.LEFT) entry = tk.Entry(frame, width=10, font=("Arial", 12)) entry.pack(side=tk.LEFT, padx=5) entry.insert(0, str(default)) self.entries[label] = entry def show_info(lbl=label): messagebox.showinfo(f"Objašnjenje: {lbl}", self.param_info.get(lbl, "Nema dostupnog opisa.")) tk.Button(frame, text="?", width=2, command=show_info).pack(side=tk.LEFT) for key in ["hfov", "yaw", "pitch", "minPitch", "maxPitch", "minHfov", "maxHfov"]: add_param(key, self.defaults[key]) vaov_frame = tk.LabelFrame(self.right_frame, text="VAOV (vertikalni ugao)", font=("Arial", 11, "bold")) vaov_frame.pack(padx=10, pady=10, fill=tk.X) vaov_inner = tk.Frame(vaov_frame) vaov_inner.pack(fill=tk.X) self.vaov_entry = tk.Entry(vaov_inner, font=("Arial", 12), width=10) self.vaov_entry.pack(side=tk.LEFT, padx=5, pady=5) self.vaov_entry.insert(0, str(self.defaults["vaov"])) tk.Button(vaov_inner, text="?", width=2, command=lambda: messagebox.showinfo("Objašnjenje: vaov", self.param_info["vaov"])).pack(side=tk.LEFT, padx=2) self.btn_generate = tk.Button(self.right_frame, text="Generiši HTML turu", font=("Arial", 12, "bold"), command=self.generate_html) self.btn_generate.pack(pady=20, fill=tk.X) self.info_label = tk.Label( self.right_frame, #text="""\nIzaberi ciljnu scenu sa desne strane.""", text="""Izaberi ciljnu scenu sa desne strane.\n\Klikni na panoramu da dodaj hotspot.\nUnesi tekst ako želiš.""", fg="blue", wraplength=300, justify="center" ) self.info_label.pack(pady=10) def open_folder(self): folder = filedialog.askdirectory() if folder: self.folder = folder self.images = sorted([f for f in os.listdir(folder) if f.lower().endswith(".jpg")]) self.lst_images.delete(0, tk.END) for img in self.images: self.lst_images.insert(tk.END, img) messagebox.showinfo("Folder učitan", f"Učitano {len(self.images)} slika iz:\n{folder}") def set_current_pano(self): sel = self.lst_images.curselection() if not sel: return self.current_pano = self.images[sel[0]] img_path = os.path.join(self.folder, self.current_pano) img = Image.open(img_path) max_h = self.winfo_height() - 100 scale = max_h / img.height new_size = (int(img.width * scale), int(img.height * scale)) img = img.resize(new_size, Image.LANCZOS) self.pano_img = ImageTk.PhotoImage(img) self.canvas.itemconfig(self.pano_image_container, image=self.pano_img) self.canvas.config(scrollregion=(0, 0, new_size[0], new_size[1])) if self.current_pano not in self.hotspots: self.hotspots[self.current_pano] = [] def on_pano_click(self, event): if not self.current_pano: return sel = self.lst_images.curselection() if not sel: messagebox.showwarning("Greška", "Izaberi ciljnu sliku za hotspot.") return target = self.images[sel[0]] x = self.canvas.canvasx(event.x) y = self.canvas.canvasy(event.y) x_norm = x / self.pano_img.width() y_norm = y / self.pano_img.height() yaw = x_norm * 360 - 180 pitch = 90 - y_norm * 180 text = simpledialog.askstring("Opis hotspot-a", "Unesi opis za hotspot:") if text is None: return self.hotspots[self.current_pano].append({ "yaw": round(yaw, 1), "pitch": round(pitch, 1), "target": target, "text": text }) messagebox.showinfo("Hotspot dodat", f"Na {self.current_pano}: → {target}\nYaw: {yaw:.1f}, Pitch: {pitch:.1f}") def show_selected_image_preview(self, event): sel = self.lst_images.curselection() if not sel: return fname = self.images[sel[0]] path = os.path.join(self.folder, fname) try: img = Image.open(path) img.thumbnail((260, 180)) self.tk_preview = ImageTk.PhotoImage(img) self.preview_label.config(image=self.tk_preview) except Exception as e: self.preview_label.config(text=str(e), image="") def generate_html(self): try: vaov_value = float(self.vaov_entry.get()) except ValueError: messagebox.showwarning("Greška", "VAOV mora biti broj.") return def get_float(name, default): try: return float(self.entries[name].get()) except Exception: return default hfov = get_float("hfov", self.defaults["hfov"]) yaw = get_float("yaw", self.defaults["yaw"]) pitch = get_float("pitch", self.defaults["pitch"]) minPitch = get_float("minPitch", self.defaults["minPitch"]) maxPitch = get_float("maxPitch", self.defaults["maxPitch"]) minHfov = get_float("minHfov", self.defaults["minHfov"]) maxHfov = get_float("maxHfov", self.defaults["maxHfov"]) html_path = os.path.join(self.folder, "tour.html") scenes = [] for pano in self.images: hs_js = [] for h in self.hotspots.get(pano, []): hs_js.append(f'''{{"yaw": {h['yaw']}, "pitch": {h['pitch']}, "type": "scene", "text": "{h['text']}", "sceneId": "{h['target']}"}}''') scene_js = f''' "{pano}": {{ "title": "{pano}", "hfov": {hfov}, "yaw": {yaw}, "pitch": {pitch}, "type": "equirectangular", "panorama": "{pano}", "hotSpots": [{','.join(hs_js)}], "minPitch": {minPitch}, "maxPitch": {maxPitch}, "minHfov": {minHfov}, "maxHfov": {maxHfov}, "vaov": {vaov_value} }}''' scenes.append(scene_js) scenes_str = ",\n".join(scenes) html = f'''<!DOCTYPE HTML> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Tour</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.css"/> <script src="https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.js"></script> <style> body {{ margin: 0; overflow: hidden; }} #panorama {{ width: 100vw; height: 100vh; }} </style> </head> <body> <div id="panorama"></div> <script> pannellum.viewer('panorama', {{ "default": {{ "firstScene": "{self.images[0]}", "author": "Virtuelna tura", "sceneFadeDuration": 1000, "autoLoad": true, "showZoomCtrl": true, "showFullscreenCtrl": true, "compass": true }}, "scenes": {{ {scenes_str} }} }}); </script> </body> </html>''' with open(html_path, "w", encoding="utf-8") as f: f.write(html) messagebox.showinfo("Uspeh", f"tour.html generisan u:\n{html_path}") if __name__ == "__main__": app = PanoramaTourEditor() app.mainloop()