HirCoir commited on
Commit
ade108c
·
verified ·
1 Parent(s): 89505fb

Upload 8 files

Browse files
Files changed (8) hide show
  1. Dockerfile +27 -0
  2. app.py +199 -0
  3. community.html +146 -0
  4. index.html +156 -0
  5. main.py +348 -0
  6. requirements.txt +5 -0
  7. result.html +195 -0
  8. viewer.html +326 -0
Dockerfile ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Usa una imagen base oficial de Python
2
+ FROM python:3.10
3
+
4
+ # Agrega un usuario no root
5
+ RUN useradd -m -u 1000 app
6
+
7
+ # Establece el directorio de trabajo dentro del contenedor
8
+ WORKDIR /home/app
9
+ RUN pip install --upgrade pip
10
+
11
+ # Instala Flask en el entorno del contenedor
12
+ RUN pip install -r requirements.txt
13
+
14
+ # Copia solo los archivos necesarios para evitar copiar todo el directorio de trabajo
15
+ COPY --chown=app:app . .
16
+ RUN mkdir /home/app/templates/
17
+ COPY --chown=app:app community.html /home/app/templates/community.html
18
+ COPY --chown=app:app index.html /home/app/templates/index.html
19
+ COPY --chown=app:app result.html /home/app/templates/result.html
20
+ COPY --chown=app:app viewer.html /home/app/templates/viewer.html
21
+
22
+
23
+ # Expone el puerto en el que Flask se ejecutará dentro del contenedor
24
+ EXPOSE 7860
25
+
26
+ # Comando para ejecutar la aplicación Flask
27
+ CMD ["python", "app.py"]
app.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, render_template, request, redirect, url_for, flash, send_file
2
+ from werkzeug.utils import secure_filename
3
+ import cv2
4
+ import numpy as np
5
+ import os
6
+ from PIL import Image
7
+ import uuid
8
+ import json
9
+
10
+ app = Flask(__name__)
11
+ app.secret_key = 'clave_secreta_para_flash'
12
+
13
+ # Configuración de carpetas
14
+ UPLOAD_FOLDER = 'static/uploads'
15
+ COMMUNITY_FOLDER = 'static/community'
16
+ THUMBNAILS_FOLDER = 'static/thumbnails'
17
+ ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg'}
18
+
19
+ # Crear directorios si no existen
20
+ for folder in [UPLOAD_FOLDER, COMMUNITY_FOLDER, THUMBNAILS_FOLDER]:
21
+ os.makedirs(folder, exist_ok=True)
22
+
23
+ app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
24
+
25
+ def allowed_file(filename):
26
+ return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
27
+
28
+ def create_thumbnail(image_path, thumbnail_path, size=(1280, 720)):
29
+ """Crear miniatura comprimida para vista rápida"""
30
+ try:
31
+ with Image.open(image_path) as img:
32
+ # Calcular dimensiones manteniendo aspecto
33
+ img.thumbnail(size, Image.Resampling.LANCZOS)
34
+ # Guardar con compresión alta
35
+ img.save(thumbnail_path, 'JPEG', quality=70, optimize=True)
36
+ return True
37
+ except Exception as e:
38
+ print(f"Error creando thumbnail: {e}")
39
+ return False
40
+
41
+ def process_panorama(image_files):
42
+ imgs = []
43
+ for file in image_files:
44
+ img = cv2.imdecode(np.fromfile(file, dtype=np.uint8), cv2.IMREAD_COLOR)
45
+ if img is not None:
46
+ imgs.append(img)
47
+
48
+ if len(imgs) < 2:
49
+ return None, "Se necesitan al menos 2 imágenes válidas."
50
+
51
+ try:
52
+ stitcher = cv2.Stitcher_create(mode=cv2.Stitcher_PANORAMA)
53
+ status, pano = stitcher.stitch(imgs)
54
+
55
+ if status != cv2.Stitcher_OK:
56
+ return None, "No se pudo crear el panorama. Asegúrate de que las imágenes tengan suficiente solapamiento."
57
+
58
+ # Recortar bordes negros
59
+ pano_rgb = cv2.cvtColor(pano, cv2.COLOR_BGR2RGB)
60
+ mask = np.any(pano_rgb != [0, 0, 0], axis=2)
61
+ coords = np.column_stack(np.where(mask))
62
+
63
+ if coords.size > 0:
64
+ y_min, x_min = coords.min(axis=0)
65
+ y_max, x_max = coords.max(axis=0)
66
+ if y_min <= y_max and x_min <= x_max:
67
+ cropped = pano_rgb[y_min:y_max+1, x_min:x_max+1]
68
+ return Image.fromarray(cropped), None
69
+
70
+ return Image.fromarray(pano_rgb), None
71
+
72
+ except Exception as e:
73
+ return None, f"Error al procesar las imágenes: {str(e)}"
74
+
75
+ @app.route('/')
76
+ def index():
77
+ return render_template('index.html')
78
+
79
+ @app.route('/community')
80
+ def community():
81
+ """Página de comunidad con vistas tipo YouTube"""
82
+ community_images = []
83
+ for filename in os.listdir(COMMUNITY_FOLDER):
84
+ if filename.endswith(('.jpg', '.jpeg', '.png')):
85
+ # Crear thumbnail si no existe
86
+ thumbnail_name = f"thumb_{filename}"
87
+ thumbnail_path = os.path.join(THUMBNAILS_FOLDER, thumbnail_name)
88
+ if not os.path.exists(thumbnail_path):
89
+ create_thumbnail(
90
+ os.path.join(COMMUNITY_FOLDER, filename),
91
+ thumbnail_path
92
+ )
93
+
94
+ community_images.append({
95
+ 'filename': filename,
96
+ 'thumbnail': f'thumbnails/{thumbnail_name}',
97
+ 'full_image': f'community/{filename}'
98
+ })
99
+
100
+ return render_template('community.html', images=community_images)
101
+
102
+ @app.route('/viewer/<filename>')
103
+ def panorama_viewer(filename):
104
+ """Visualizador de panorama cilíndrico"""
105
+ image_path = f'community/{filename}'
106
+ return render_template('viewer.html', image_path=image_path, filename=filename)
107
+
108
+ @app.route('/upload', methods=['POST'])
109
+ def upload_files():
110
+ if 'files[]' not in request.files:
111
+ flash('No se seleccionaron archivos')
112
+ return redirect(url_for('index'))
113
+
114
+ files = request.files.getlist('files[]')
115
+ if not files or files[0].filename == '':
116
+ flash('No se seleccionaron archivos')
117
+ return redirect(url_for('index'))
118
+
119
+ # Verificar y guardar archivos temporalmente
120
+ temp_paths = []
121
+ for file in files:
122
+ if file and allowed_file(file.filename):
123
+ filename = secure_filename(file.filename)
124
+ filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
125
+ file.save(filepath)
126
+ temp_paths.append(filepath)
127
+
128
+ if len(temp_paths) < 2:
129
+ flash('Se necesitan al menos 2 imágenes')
130
+ return redirect(url_for('index'))
131
+
132
+ # Procesar panorama
133
+ result_image, error = process_panorama(temp_paths)
134
+
135
+ # Limpiar archivos temporales
136
+ for path in temp_paths:
137
+ try:
138
+ os.remove(path)
139
+ except:
140
+ pass
141
+
142
+ if error:
143
+ flash(error)
144
+ return redirect(url_for('index'))
145
+
146
+ # Guardar resultado
147
+ result_filename = f'result_{uuid.uuid4().hex[:8]}.jpg'
148
+ result_path = os.path.join(app.config['UPLOAD_FOLDER'], result_filename)
149
+ result_image.save(result_path, 'JPEG', quality=95)
150
+
151
+ return render_template('result.html', result_image=f'uploads/{result_filename}', filename=result_filename)
152
+
153
+ @app.route('/share', methods=['POST'])
154
+ def share_panorama():
155
+ result_path = request.form.get('result_path')
156
+ if not result_path:
157
+ flash('No hay imagen para compartir')
158
+ return redirect(url_for('index'))
159
+
160
+ try:
161
+ # Generar nombre único para la imagen compartida
162
+ original_filename = os.path.basename(result_path)
163
+ shared_filename = f'shared_{uuid.uuid4().hex[:8]}.jpg'
164
+ original_path = os.path.join('static', result_path)
165
+ shared_path = os.path.join(COMMUNITY_FOLDER, shared_filename)
166
+
167
+ # Copiar imagen a la carpeta de comunidad
168
+ Image.open(original_path).save(shared_path, 'JPEG', quality=95)
169
+
170
+ # Crear thumbnail para la nueva imagen
171
+ thumbnail_name = f"thumb_{shared_filename}"
172
+ thumbnail_path = os.path.join(THUMBNAILS_FOLDER, thumbnail_name)
173
+ create_thumbnail(shared_path, thumbnail_path)
174
+
175
+ # Eliminar imagen temporal
176
+ os.remove(original_path)
177
+
178
+ flash('¡Imagen compartida exitosamente!')
179
+ except Exception as e:
180
+ flash(f'Error al compartir la imagen: {str(e)}')
181
+
182
+ return redirect(url_for('community'))
183
+
184
+ @app.route('/download/<filename>')
185
+ def download_file(filename):
186
+ """Descargar archivo de resultado"""
187
+ try:
188
+ file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
189
+ if os.path.exists(file_path):
190
+ return send_file(file_path, as_attachment=True, download_name=f'panorama_{filename}')
191
+ else:
192
+ flash('Archivo no encontrado')
193
+ return redirect(url_for('index'))
194
+ except Exception as e:
195
+ flash(f'Error al descargar: {str(e)}')
196
+ return redirect(url_for('index'))
197
+
198
+ if __name__ == '__main__':
199
+ app.run(debug=True)
community.html ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Comunidad de Panoramas</title>
7
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
8
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
9
+ <style>
10
+ .video-grid {
11
+ display: grid;
12
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
13
+ gap: 20px;
14
+ margin-top: 20px;
15
+ }
16
+ .video-card {
17
+ background: white;
18
+ border-radius: 12px;
19
+ overflow: hidden;
20
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
21
+ transition: transform 0.2s, box-shadow 0.2s;
22
+ cursor: pointer;
23
+ }
24
+ .video-card:hover {
25
+ transform: translateY(-4px);
26
+ box-shadow: 0 4px 16px rgba(0,0,0,0.15);
27
+ }
28
+ .thumbnail-container {
29
+ position: relative;
30
+ width: 100%;
31
+ height: 180px;
32
+ overflow: hidden;
33
+ }
34
+ .thumbnail {
35
+ width: 100%;
36
+ height: 100%;
37
+ object-fit: cover;
38
+ transition: transform 0.3s;
39
+ }
40
+ .video-card:hover .thumbnail {
41
+ transform: scale(1.05);
42
+ }
43
+ .play-overlay {
44
+ position: absolute;
45
+ top: 50%;
46
+ left: 50%;
47
+ transform: translate(-50%, -50%);
48
+ background: rgba(0,0,0,0.7);
49
+ color: white;
50
+ border-radius: 50%;
51
+ width: 60px;
52
+ height: 60px;
53
+ display: flex;
54
+ align-items: center;
55
+ justify-content: center;
56
+ font-size: 24px;
57
+ opacity: 0;
58
+ transition: opacity 0.3s;
59
+ }
60
+ .video-card:hover .play-overlay {
61
+ opacity: 1;
62
+ }
63
+ .video-info {
64
+ padding: 15px;
65
+ }
66
+ .video-title {
67
+ font-weight: 600;
68
+ margin-bottom: 8px;
69
+ color: #333;
70
+ }
71
+ .video-meta {
72
+ color: #666;
73
+ font-size: 14px;
74
+ }
75
+ .empty-state {
76
+ text-align: center;
77
+ padding: 60px 20px;
78
+ color: #666;
79
+ }
80
+ .empty-state i {
81
+ font-size: 64px;
82
+ margin-bottom: 20px;
83
+ color: #ddd;
84
+ }
85
+ </style>
86
+ </head>
87
+ <body class="bg-light">
88
+ <div class="container py-4">
89
+ <div class="d-flex justify-content-between align-items-center mb-4">
90
+ <h1 class="mb-0">Comunidad de Panoramas</h1>
91
+ <a href="{{ url_for('index') }}" class="btn btn-primary">
92
+ <i class="bi bi-plus-circle"></i> Crear Panorama
93
+ </a>
94
+ </div>
95
+
96
+ {% with messages = get_flashed_messages() %}
97
+ {% if messages %}
98
+ {% for message in messages %}
99
+ <div class="alert alert-success alert-dismissible fade show" role="alert">
100
+ {{ message }}
101
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
102
+ </div>
103
+ {% endfor %}
104
+ {% endif %}
105
+ {% endwith %}
106
+
107
+ {% if images %}
108
+ <div class="video-grid">
109
+ {% for image in images %}
110
+ <div class="video-card" onclick="openPanorama('{{ image.filename }}')"> <div class="thumbnail-container">
111
+ <img src="{{ url_for('static', filename=image.thumbnail) }}"
112
+ class="thumbnail"
113
+ alt="Panorama thumbnail">
114
+ <div class="play-overlay">
115
+ <i class="bi bi-play-fill"></i>
116
+ </div>
117
+ </div>
118
+ <div class="video-info">
119
+ <div class="video-title">Panorama {{ loop.index }}</div>
120
+ <div class="video-meta">
121
+ <i class="bi bi-eye"></i> Vista panorámica
122
+ </div>
123
+ </div>
124
+ </div>
125
+ {% endfor %}
126
+ </div>
127
+ {% else %}
128
+ <div class="empty-state">
129
+ <i class="bi bi-images"></i>
130
+ <h3>No hay panoramas aún</h3>
131
+ <p>Sé el primero en compartir un panorama con la comunidad</p>
132
+ <a href="{{ url_for('index') }}" class="btn btn-primary">
133
+ <i class="bi bi-plus-circle"></i> Crear mi primer panorama
134
+ </a>
135
+ </div>
136
+ {% endif %}
137
+ </div>
138
+
139
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
140
+ <script>
141
+ function openPanorama(filename) {
142
+ window.open(`{{ url_for('panorama_viewer', filename='') }}${filename}`, '_blank');
143
+ }
144
+ </script>
145
+ </body>
146
+ </html>
index.html ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Generador de Panoramas</title>
7
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
8
+ <style>
9
+ .preview-container {
10
+ max-height: 300px;
11
+ overflow-y: auto;
12
+ }
13
+ .community-image {
14
+ max-width: 100%;
15
+ height: auto;
16
+ margin-bottom: 15px;
17
+ border-radius: 8px;
18
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
19
+ }
20
+ .upload-area {
21
+ border: 2px dashed #ccc;
22
+ border-radius: 8px;
23
+ padding: 20px;
24
+ text-align: center;
25
+ background-color: #f8f9fa;
26
+ cursor: pointer;
27
+ }
28
+ .upload-area:hover {
29
+ border-color: #0d6efd;
30
+ background-color: #f1f3f5;
31
+ }
32
+ #fileList {
33
+ margin-top: 10px;
34
+ padding: 0;
35
+ list-style: none;
36
+ }
37
+ </style>
38
+ </head>
39
+ <body class="bg-light">
40
+ <div class="container py-5">
41
+ <div class="d-flex justify-content-between align-items-center mb-4">
42
+ <h1 class="mb-0">Generador de Panoramas</h1>
43
+ <a href="{{ url_for('community') }}" class="btn btn-outline-primary">
44
+ <i class="bi bi-people"></i> Ver Comunidad
45
+ </a>
46
+ </div>
47
+
48
+ {% with messages = get_flashed_messages() %}
49
+ {% if messages %}
50
+ {% for message in messages %}
51
+ <div class="alert alert-info alert-dismissible fade show" role="alert">
52
+ {{ message }}
53
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
54
+ </div>
55
+ {% endfor %}
56
+ {% endif %}
57
+ {% endwith %}
58
+
59
+ <div class="row">
60
+ <div class="col-md-8 mb-4">
61
+ <div class="card">
62
+ <div class="card-body">
63
+ <h5 class="card-title">Crear Nuevo Panorama</h5>
64
+ <form action="{{ url_for('upload_files') }}" method="post" enctype="multipart/form-data">
65
+ <div class="upload-area mb-3" id="dropZone" onclick="document.getElementById('fileInput').click();">
66
+ <i class="bi bi-cloud-upload"></i>
67
+ <p class="mb-0">Haz clic aquí o arrastra tus imágenes</p>
68
+ <small class="text-muted">Selecciona al menos 2 imágenes</small>
69
+ <input type="file" id="fileInput" name="files[]" multiple accept=".jpg,.jpeg,.png" style="display: none;" onchange="updateFileList()">
70
+ </div>
71
+ <ul id="fileList" class="list-group"></ul>
72
+ <button type="submit" class="btn btn-primary mt-3" id="submitBtn" disabled>Crear Panorama</button>
73
+ </form>
74
+ </div>
75
+ </div>
76
+ </div>
77
+
78
+ <div class="col-md-4">
79
+ <div class="card">
80
+ <div class="card-body">
81
+ <h5 class="card-title">Instrucciones</h5>
82
+ <div class="text-muted">
83
+ <p><strong>1.</strong> Selecciona al menos 2 imágenes que se solapen</p>
84
+ <p><strong>2.</strong> Haz clic en "Crear Panorama"</p>
85
+ <p><strong>3.</strong> Espera el procesamiento</p>
86
+ <p><strong>4.</strong> Descarga o comparte tu resultado</p>
87
+ <hr>
88
+ <p class="text-center">
89
+ <a href="{{ url_for('community') }}" class="btn btn-sm btn-outline-primary">
90
+ Ver panoramas de la comunidad
91
+ </a>
92
+ </p>
93
+ </div>
94
+ </div>
95
+ </div>
96
+ </div>
97
+ </div>
98
+ </div>
99
+
100
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
101
+ <script>
102
+ function updateFileList() {
103
+ const input = document.getElementById('fileInput');
104
+ const fileList = document.getElementById('fileList');
105
+ const submitBtn = document.getElementById('submitBtn');
106
+
107
+ fileList.innerHTML = '';
108
+ submitBtn.disabled = input.files.length < 2;
109
+
110
+ Array.from(input.files).forEach(file => {
111
+ const li = document.createElement('li');
112
+ li.className = 'list-group-item d-flex justify-content-between align-items-center';
113
+ li.textContent = file.name;
114
+ fileList.appendChild(li);
115
+ });
116
+ }
117
+
118
+ // Drag and drop functionality
119
+ const dropZone = document.getElementById('dropZone');
120
+
121
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
122
+ dropZone.addEventListener(eventName, preventDefaults, false);
123
+ });
124
+
125
+ function preventDefaults(e) {
126
+ e.preventDefault();
127
+ e.stopPropagation();
128
+ }
129
+
130
+ ['dragenter', 'dragover'].forEach(eventName => {
131
+ dropZone.addEventListener(eventName, highlight, false);
132
+ });
133
+
134
+ ['dragleave', 'drop'].forEach(eventName => {
135
+ dropZone.addEventListener(eventName, unhighlight, false);
136
+ });
137
+
138
+ function highlight(e) {
139
+ dropZone.classList.add('bg-light');
140
+ }
141
+
142
+ function unhighlight(e) {
143
+ dropZone.classList.remove('bg-light');
144
+ }
145
+
146
+ dropZone.addEventListener('drop', handleDrop, false);
147
+
148
+ function handleDrop(e) {
149
+ const dt = e.dataTransfer;
150
+ const files = dt.files;
151
+ document.getElementById('fileInput').files = files;
152
+ updateFileList();
153
+ }
154
+ </script>
155
+ </body>
156
+ </html>
main.py ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import tkinter as tk
2
+ from tkinter import ttk, filedialog, messagebox
3
+ from PIL import Image, ImageTk
4
+ import cv2
5
+ import numpy as np
6
+ import os
7
+ import threading
8
+
9
+ # Desactivar OpenCL en OpenCV para evitar posibles errores
10
+ cv2.ocl.setUseOpenCL(False)
11
+
12
+ class PanoramaApp(tk.Tk):
13
+ def __init__(self):
14
+ super().__init__()
15
+ self.title("Generador de Panorama")
16
+ self.geometry("950x650") # Aumentar un poco el tamaño inicial
17
+ self.minsize(700, 500) # Aumentar el tamaño mínimo
18
+
19
+ # Configurar grid para que todos los elementos crezcan con la ventana
20
+ self.grid_columnconfigure(0, weight=1) # Columna para el marco principal
21
+ self.grid_rowconfigure(0, weight=0) # barra superior
22
+ self.grid_rowconfigure(1, weight=1) # área de imagen
23
+
24
+ # --------- Barra superior con controles ---------
25
+ toolbar = ttk.Frame(self)
26
+ toolbar.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
27
+ # Configurar columnas de la toolbar
28
+ toolbar.grid_columnconfigure(0, weight=1) # Botón seleccionar
29
+ toolbar.grid_columnconfigure(1, weight=1) # Contador imágenes
30
+ toolbar.grid_columnconfigure(2, weight=1) # Label Modo
31
+ toolbar.grid_columnconfigure(3, weight=1) # Combobox Modo
32
+ toolbar.grid_columnconfigure(4, weight=1) # Botón Crear
33
+
34
+ self.btn_select = ttk.Button(toolbar, text="Seleccionar Imágenes", command=self.seleccionar_imagenes)
35
+ self.btn_select.grid(row=0, column=0, padx=5, pady=5, sticky="w")
36
+
37
+ self.lbl_count = ttk.Label(toolbar, text="0 imágenes seleccionadas")
38
+ self.lbl_count.grid(row=0, column=1, padx=5, pady=5, sticky="w")
39
+
40
+ # --- Nuevo: Selector de Modo de Stitching ---
41
+ self.lbl_mode = ttk.Label(toolbar, text="Modo de Unión:")
42
+ self.lbl_mode.grid(row=0, column=2, padx=(20,2), pady=5, sticky="e")
43
+
44
+ self.stitching_mode_var = tk.StringVar(self)
45
+ # Opciones disponibles y valor por defecto
46
+ self.stitching_modes = {
47
+ "Panorama (giro fijo)": "panorama",
48
+ "Escaneo (ángulos varios)": "scans"
49
+ }
50
+ # Obtener las claves para mostrar en el combobox
51
+ mode_display_names = list(self.stitching_modes.keys())
52
+ self.stitching_mode_var.set(mode_display_names[0]) # Por defecto "Panorama (giro fijo)"
53
+
54
+ self.mode_combobox = ttk.Combobox(toolbar,
55
+ textvariable=self.stitching_mode_var,
56
+ values=mode_display_names,
57
+ state="readonly", # No permitir escribir
58
+ width=20)
59
+ self.mode_combobox.grid(row=0, column=3, padx=5, pady=5, sticky="w")
60
+ # ---------------------------------------------
61
+
62
+ self.btn_stitch = ttk.Button(toolbar, text="Crear Panorama", command=self.crear_panorama, state="disabled")
63
+ self.btn_stitch.grid(row=0, column=4, padx=5, pady=5, sticky="e")
64
+
65
+
66
+ # --------- Área para mostrar el resultado ---------
67
+ display_frame = ttk.Frame(self, relief="sunken")
68
+ display_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
69
+ display_frame.grid_rowconfigure(0, weight=1)
70
+ display_frame.grid_columnconfigure(0, weight=1)
71
+
72
+ self.canvas = tk.Canvas(display_frame, bg="#333333")
73
+ self.canvas.grid(row=0, column=0, sticky="nsew")
74
+
75
+ # --------- Menú contextual para el canvas ---------
76
+ self.context_menu = tk.Menu(self.canvas, tearoff=0)
77
+ self.context_menu.add_command(label="Guardar Panorama...", command=self.guardar_panorama)
78
+
79
+ # Bind events
80
+ self.canvas.bind("<Configure>", self._on_canvas_resize)
81
+ self.canvas.bind("<Button-3>", self._show_context_menu) # Clic derecho
82
+
83
+ # Variables internas
84
+ self.rutas_imagenes = []
85
+ self.panorama_tk = None
86
+ self.final_panorama_img_np = None
87
+ self.stitch_thread = None
88
+
89
+ def seleccionar_imagenes(self):
90
+ # Si hay un proceso de stitching corriendo, no permitir seleccionar nuevas imágenes
91
+ if self.stitch_thread and self.stitch_thread.is_alive():
92
+ messagebox.showwarning("Proceso en curso", "Por favor, espera a que termine el proceso actual.")
93
+ return
94
+
95
+ rutas = filedialog.askopenfilenames(
96
+ title="Selecciona las imágenes",
97
+ filetypes=[("Imágenes JPEG/PNG", "*.jpg *.jpeg *.png"), ("Todos los archivos", "*.*")]
98
+ )
99
+ if not rutas:
100
+ return
101
+
102
+ self.rutas_imagenes = list(rutas)
103
+ count = len(self.rutas_imagenes)
104
+ self.lbl_count.config(text=f"{count} imagen(es) seleccionada(s)")
105
+
106
+ # Limpiar el canvas y variables de imagen anterior
107
+ self.canvas.delete("all")
108
+ self.panorama_tk = None
109
+ self.final_panorama_img_np = None
110
+
111
+ # Habilitar el botón de crear panorama solo si hay al menos 2 imágenes
112
+ if count >= 2:
113
+ self.btn_stitch.config(state="normal")
114
+ else:
115
+ self.btn_stitch.config(state="disabled")
116
+
117
+ def crear_panorama(self):
118
+ if len(self.rutas_imagenes) < 2:
119
+ messagebox.showwarning("Atención", "Selecciona al menos 2 imágenes para crear un panorama.")
120
+ return
121
+
122
+ # Deshabilitar botones y mostrar estado
123
+ self.btn_stitch.config(text="Procesando...", state="disabled")
124
+ self.btn_select.config(state="disabled")
125
+ self.mode_combobox.config(state="disabled") # Deshabilitar selector de modo
126
+ self.update_idletasks() # Forzar actualización de la GUI
127
+
128
+ # Limpiar el canvas y variables de imagen anterior antes de empezar
129
+ self.canvas.delete("all")
130
+ self.panorama_tk = None
131
+ self.final_panorama_img_np = None
132
+
133
+ # Obtener el modo de stitching seleccionado
134
+ selected_mode_display = self.stitching_mode_var.get()
135
+ # Mapear el nombre mostrado al valor interno de OpenCV
136
+ stitching_mode_internal = self.stitching_modes.get(selected_mode_display, "panorama") # Por defecto "panorama"
137
+
138
+ # Iniciar el proceso de stitching en un hilo separado
139
+ self.stitch_thread = threading.Thread(
140
+ target=self._process_stitching_in_thread,
141
+ args=(self.rutas_imagenes, stitching_mode_internal)
142
+ )
143
+ self.stitch_thread.start()
144
+
145
+ def _process_stitching_in_thread(self, rutas_imagenes, stitching_mode):
146
+ """
147
+ Método que contiene la lógica pesada de stitching, ejecutada en un hilo.
148
+ stitching_mode: "panorama" o "scans"
149
+ """
150
+ imgs = []
151
+ for ruta in rutas_imagenes:
152
+ try:
153
+ img = cv2.imdecode(np.fromfile(ruta, dtype=np.uint8), cv2.IMREAD_COLOR)
154
+ if img is None:
155
+ self.after(0, self._on_stitching_complete, cv2.Stitcher_ERR_NEED_MORE_IMGS, None, None, f"No se pudo leer la imagen: {os.path.basename(ruta)}. Asegúrate de que es un archivo de imagen válido y no está corrupto.")
156
+ return
157
+ imgs.append(img)
158
+ except Exception as e:
159
+ self.after(0, self._on_stitching_complete, cv2.Stitcher_ERR_OTHER, None, None, f"Error al cargar {os.path.basename(ruta)}: {e}")
160
+ return
161
+
162
+ if len(imgs) < 2:
163
+ self.after(0, self._on_stitching_complete, cv2.Stitcher_ERR_NEED_MORE_IMGS, None, None, "Se necesitan al menos 2 imágenes válidas.")
164
+ return
165
+
166
+ # Determinar el modo de Stitcher basado en la selección del usuario
167
+ stitcher_mode_cv = cv2.Stitcher_PANORAMA # Por defecto
168
+ if stitching_mode == "scans":
169
+ try:
170
+ # Comprobar si cv2.Stitcher_SCANS existe
171
+ stitcher_mode_cv = cv2.Stitcher_SCANS
172
+ except AttributeError:
173
+ self.after(0, self._on_stitching_complete, cv2.Stitcher_ERR_OTHER, None, None, "Tu versión de OpenCV no soporta el modo 'SCANS'. Utilizando 'PANORAMA'.")
174
+ stitcher_mode_cv = cv2.Stitcher_PANORAMA # Vuelve a Panorama si no existe SCANS
175
+
176
+ # Crear el objeto Stitcher
177
+ try:
178
+ stitcher = cv2.Stitcher_create(mode=stitcher_mode_cv)
179
+ except AttributeError:
180
+ # Para versiones antiguas de OpenCV (<4.0) o si create(mode) no existe
181
+ try:
182
+ stitcher = cv2.createStitcher(False) # False indica no usar calibración de cámara
183
+ self.after(0, self._on_stitching_complete, cv2.Stitcher_ERR_OTHER, None, None, "Tu versión de OpenCV es antigua. Usando createStitcher() sin modo específico.")
184
+ except AttributeError:
185
+ self.after(0, self._on_stitching_complete, cv2.Stitcher_ERR_OTHER, None, None, "Tu versión de OpenCV no soporta Stitcher o está mal instalada.")
186
+ return
187
+
188
+ status, pano = stitcher.stitch(imgs)
189
+
190
+ if status != cv2.Stitcher_OK:
191
+ # Llamar a la función de completado con error en el hilo principal
192
+ self.after(0, self._on_stitching_complete, status, None, None, None)
193
+ return
194
+
195
+ # ======== Bloque para recortar los bordes negros ========
196
+ # Convertir de BGR (OpenCV) a RGB (para Pillow/visualización y procesamiento con numpy)
197
+ pano_rgb = cv2.cvtColor(pano, cv2.COLOR_BGR2RGB)
198
+
199
+ # Crear una máscara booleana: True donde el píxel no es (0,0,0) (negro)
200
+ # np.any(..., axis=2) verifica si CUALQUIER canal RGB no es 0 para ese píxel.
201
+ # Esto es más robusto que solo == [0,0,0] si el negro es (0,0,1) por ejemplo.
202
+ mask = np.any(pano_rgb != [0, 0, 0], axis=2)
203
+
204
+ # Encontrar las coordenadas mínimas y máximas de la región válida (no negra)
205
+ coords = np.column_stack(np.where(mask))
206
+ cropped = pano_rgb # Inicializar con la imagen completa por si no hay contenido válido
207
+
208
+ if coords.size > 0:
209
+ y_min, x_min = coords.min(axis=0)
210
+ y_max, x_max = coords.max(axis=0)
211
+ # Asegurarse de que las coordenadas son válidas
212
+ if y_min <= y_max and x_min <= x_max:
213
+ cropped = pano_rgb[y_min:y_max+1, x_min:x_max+1]
214
+ # else: Si las coordenadas no son válidas, `cropped` se queda como `pano_rgb`
215
+ # ===============================================================
216
+
217
+ # Llamar a la función de completado con el resultado en el hilo principal
218
+ self.after(0, self._on_stitching_complete, status, pano, cropped, None)
219
+
220
+ def _on_stitching_complete(self, status, pano, cropped, error_message=None):
221
+ """
222
+ Método llamado en el hilo principal cuando el stitching termina.
223
+ """
224
+ # Restaurar el estado de los botones y el selector de modo
225
+ self.btn_stitch.config(text="Crear Panorama", state="normal")
226
+ self.btn_select.config(state="normal")
227
+ self.mode_combobox.config(state="readonly") # Habilitar selector de modo
228
+
229
+ if status != cv2.Stitcher_OK:
230
+ msg = "No se pudo crear el panorama."
231
+ if error_message:
232
+ msg += f"\nDetalles: {error_message}"
233
+ else:
234
+ msg += f" (código de error {status})"
235
+
236
+ if status == cv2.Stitcher_ERR_NEED_MORE_IMGS:
237
+ msg += "\nAsegúrate de que las imágenes tienen suficiente solapamiento o características distintivas."
238
+ elif status == cv2.Stitcher_ERR_HOMOGRAPHY_EST_FAIL:
239
+ msg += "\nFallo en la estimación de la homografía. Asegúrate de que las imágenes se solapan bien y no hay distorsión excesiva, o prueba con el otro modo de unión."
240
+ elif status == cv2.Stitcher_ERR_CAMERA_PARAMS_ADJUST_FAIL:
241
+ msg += "\nFallo al ajustar parámetros de cámara."
242
+ elif status == cv2.Stitcher_ERR_NO_FEATURES:
243
+ msg += "\nNo se encontraron suficientes características distintivas en las imágenes. Prueba con imágenes más detalladas o con más solapamiento."
244
+
245
+ messagebox.showerror("Error de Panorama", msg)
246
+ self.final_panorama_img_np = None # Asegurar que no haya imagen inválida almacenada
247
+ else:
248
+ # Almacenar la imagen numpy recortada (en RGB) para redimensionar y guardar
249
+ self.final_panorama_img_np = cropped
250
+ self._display_panorama()
251
+ messagebox.showinfo("Proceso Completado", "Panorama creado exitosamente. Puedes guardarlo con clic derecho.")
252
+
253
+ self.stitch_thread = None # Liberar la referencia al hilo
254
+
255
+ def _display_panorama(self, event=None):
256
+ """
257
+ Redimensiona y muestra self.final_panorama_img_np en el canvas.
258
+ Llamado después de completar el stitching y en cada redimensionamiento del canvas.
259
+ """
260
+ if self.final_panorama_img_np is None:
261
+ self.canvas.delete("all")
262
+ self.panorama_tk = None
263
+ return
264
+
265
+ w_canvas = self.canvas.winfo_width()
266
+ h_canvas = self.canvas.winfo_height()
267
+
268
+ if w_canvas <= 1 or h_canvas <= 1:
269
+ return
270
+
271
+ pil_img = Image.fromarray(self.final_panorama_img_np)
272
+
273
+ original_w, original_h = pil_img.size
274
+
275
+ # Calcular el nuevo tamaño manteniendo la relación de aspecto, ajustándose al canvas
276
+ ratio_w = w_canvas / original_w
277
+ ratio_h = h_canvas / original_h
278
+ ratio = min(ratio_w, ratio_h) # Para que quepa completamente dentro del canvas
279
+
280
+ new_w = int(original_w * ratio)
281
+ new_h = int(original_h * ratio)
282
+
283
+ new_w = max(1, new_w)
284
+ new_h = max(1, new_h)
285
+
286
+ try:
287
+ pil_img_resized = pil_img.resize((new_w, new_h), Image.Resampling.LANCZOS)
288
+ except AttributeError:
289
+ pil_img_resized = pil_img.resize((new_w, new_h), Image.LANCZOS)
290
+
291
+ self.panorama_tk = ImageTk.PhotoImage(pil_img_resized)
292
+ self.canvas.delete("all")
293
+
294
+ x = (w_canvas - new_w) // 2
295
+ y = (h_canvas - new_h) // 2
296
+
297
+ self.canvas.create_image(x, y, anchor="nw", image=self.panorama_tk)
298
+
299
+ def _on_canvas_resize(self, event):
300
+ """
301
+ Maneja el evento de redimensionamiento del canvas.
302
+ """
303
+ self._display_panorama(event)
304
+
305
+ def _show_context_menu(self, event):
306
+ """
307
+ Muestra el menú contextual si hay una imagen de panorama.
308
+ """
309
+ if self.final_panorama_img_np is not None:
310
+ try:
311
+ self.context_menu.post(event.x_root, event.y_root)
312
+ finally:
313
+ self.context_menu.grab_release()
314
+
315
+ def guardar_panorama(self):
316
+ """
317
+ Guarda la imagen del panorama (la versión recortada de numpy).
318
+ """
319
+ if self.final_panorama_img_np is None:
320
+ messagebox.showinfo("Nada para guardar", "No hay un panorama generado para guardar.")
321
+ return
322
+
323
+ ruta_guardado = filedialog.asksaveasfilename(
324
+ defaultextension=".jpg",
325
+ filetypes=[("JPEG", "*.jpg"), ("PNG", "*.png"), ("Todos los archivos", "*.*")],
326
+ title="Guardar panorama como..."
327
+ )
328
+
329
+ if ruta_guardado:
330
+ try:
331
+ img_to_save_bgr = cv2.cvtColor(self.final_panorama_img_np, cv2.COLOR_RGB2BGR)
332
+
333
+ ext = os.path.splitext(ruta_guardado)[1].lower()
334
+ if ext in [".jpg", ".jpeg"]:
335
+ cv2.imwrite(ruta_guardado, img_to_save_bgr, [int(cv2.IMWRITE_JPEG_QUALITY), 95])
336
+ elif ext == ".png":
337
+ cv2.imwrite(ruta_guardado, img_to_save_bgr)
338
+ else:
339
+ cv2.imwrite(ruta_guardado, img_to_save_bgr)
340
+
341
+ messagebox.showinfo("Guardado Exitoso", f"Panorama guardado en:\n{ruta_guardado}")
342
+
343
+ except Exception as e:
344
+ messagebox.showerror("Error al Guardar", f"No se pudo guardar el archivo:\n{e}")
345
+
346
+ if __name__ == "__main__":
347
+ app = PanoramaApp()
348
+ app.mainloop()
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ flask==2.3.3
2
+ Werkzeug==2.3.7
3
+ opencv-python==4.8.0.76
4
+ numpy==1.25.2
5
+ Pillow==10.0.0
result.html ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Resultado del Panorama</title>
7
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
8
+ <style>
9
+ .panorama-container {
10
+ position: relative;
11
+ width: 100%;
12
+ max-height: 70vh;
13
+ overflow: hidden;
14
+ margin-bottom: 20px;
15
+ border-radius: 8px;
16
+ box-shadow: 0 4px 8px rgba(0,0,0,0.1);
17
+ }
18
+ .panorama-image {
19
+ width: 100%;
20
+ height: auto;
21
+ display: block;
22
+ }
23
+ .action-buttons {
24
+ margin-top: 20px;
25
+ display: flex;
26
+ gap: 10px;
27
+ justify-content: center;
28
+ }
29
+ </style>
30
+ </head>
31
+ <body class="bg-light">
32
+ <div class="container py-5">
33
+ <h1 class="text-center mb-4">¡Tu Panorama está Listo!</h1>
34
+
35
+ {% with messages = get_flashed_messages() %}
36
+ {% if messages %}
37
+ {% for message in messages %}
38
+ <div class="alert alert-info alert-dismissible fade show" role="alert">
39
+ {{ message }}
40
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
41
+ </div>
42
+ {% endfor %}
43
+ {% endif %}
44
+ {% endwith %}
45
+
46
+ <div class="card">
47
+ <div class="card-body">
48
+ <div class="panorama-container">
49
+ <img src="{{ url_for('static', filename=result_image) }}"
50
+ class="panorama-image"
51
+ alt="Panorama generado">
52
+ </div>
53
+
54
+ <div class="action-buttons">
55
+ <a href="{{ url_for('download_file', filename=filename) }}"
56
+ class="btn btn-primary">
57
+ <i class="bi bi-download"></i> Descargar Panorama
58
+ </a>
59
+
60
+ <button type="button" class="btn btn-info" onclick="openViewer()">
61
+ <i class="bi bi-eye"></i> Vista Cilíndrica
62
+ </button>
63
+
64
+ <form action="{{ url_for('share_panorama') }}" method="post" style="display: inline;">
65
+ <input type="hidden" name="result_path" value="{{ result_image }}">
66
+ <button type="submit" class="btn btn-success">
67
+ <i class="bi bi-share"></i> Compartir con la Comunidad
68
+ </button>
69
+ </form>
70
+
71
+ <a href="{{ url_for('index') }}" class="btn btn-outline-secondary">
72
+ <i class="bi bi-plus-circle"></i> Crear Otro Panorama
73
+ </a>
74
+ </div>
75
+ </div>
76
+ </div>
77
+ </div>
78
+
79
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
80
+ <script>
81
+ function openViewer() {
82
+ // Crear modal para vista cilíndrica
83
+ const modal = document.createElement('div');
84
+ modal.className = 'modal fade';
85
+ modal.setAttribute('tabindex', '-1');
86
+ modal.setAttribute('role', 'dialog');
87
+ modal.setAttribute('aria-hidden', 'true');
88
+ modal.innerHTML = `
89
+ <div class="modal-dialog modal-xl">
90
+ <div class="modal-content">
91
+ <div class="modal-header">
92
+ <h5 class="modal-title">Vista Panorámica Cilíndrica</h5>
93
+ <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
94
+ </div>
95
+ <div class="modal-body p-0">
96
+ <div id="panoramaViewer" style="height: 500px; width: 100%;"></div>
97
+ </div>
98
+ </div>
99
+ </div>
100
+ `;
101
+ document.body.appendChild(modal);
102
+
103
+ const modalInstance = new bootstrap.Modal(modal);
104
+
105
+ // Cargar el visor panorámico después de que el modal se muestre completamente
106
+ modal.addEventListener('shown.bs.modal', function() {
107
+ loadPanoramaViewer();
108
+ });
109
+
110
+ modalInstance.show();
111
+
112
+ modal.addEventListener('hidden.bs.modal', function () {
113
+ document.body.removeChild(modal);
114
+ });
115
+ }
116
+
117
+ function loadPanoramaViewer() {
118
+ const viewer = document.getElementById('panoramaViewer');
119
+ const imageUrl = '{{ url_for("static", filename=result_image) }}';
120
+
121
+ // Crear canvas para el visor panorámico
122
+ const canvas = document.createElement('canvas');
123
+ canvas.width = viewer.offsetWidth;
124
+ canvas.height = viewer.offsetHeight;
125
+ canvas.style.cursor = 'grab';
126
+ viewer.appendChild(canvas);
127
+
128
+ const ctx = canvas.getContext('2d');
129
+ const img = new Image();
130
+
131
+ let isDragging = false;
132
+ let lastX = 0;
133
+ let offsetX = 0;
134
+
135
+ img.onload = function() {
136
+ drawPanorama();
137
+ };
138
+
139
+ function drawPanorama() {
140
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
141
+
142
+ // Calcular escala para ajustar la imagen al canvas
143
+ const scale = canvas.height / img.height;
144
+ const scaledWidth = img.width * scale;
145
+
146
+ // Dibujar la imagen con offset para simular rotación
147
+ ctx.drawImage(img, offsetX, 0, scaledWidth, canvas.height);
148
+
149
+ // Si la imagen no cubre todo el ancho, repetir
150
+ if (scaledWidth + offsetX < canvas.width) {
151
+ ctx.drawImage(img, offsetX + scaledWidth, 0, scaledWidth, canvas.height);
152
+ }
153
+ if (offsetX > 0) {
154
+ ctx.drawImage(img, offsetX - scaledWidth, 0, scaledWidth, canvas.height);
155
+ }
156
+ }
157
+
158
+ // Eventos de mouse para arrastrar
159
+ canvas.addEventListener('mousedown', (e) => {
160
+ isDragging = true;
161
+ lastX = e.clientX;
162
+ canvas.style.cursor = 'grabbing';
163
+ });
164
+
165
+ canvas.addEventListener('mousemove', (e) => {
166
+ if (isDragging) {
167
+ const deltaX = e.clientX - lastX;
168
+ offsetX += deltaX;
169
+
170
+ // Mantener el offset en un rango válido
171
+ const scale = canvas.height / img.height;
172
+ const scaledWidth = img.width * scale;
173
+ if (offsetX > scaledWidth) offsetX -= scaledWidth;
174
+ if (offsetX < -scaledWidth) offsetX += scaledWidth;
175
+
176
+ drawPanorama();
177
+ lastX = e.clientX;
178
+ }
179
+ });
180
+
181
+ canvas.addEventListener('mouseup', () => {
182
+ isDragging = false;
183
+ canvas.style.cursor = 'grab';
184
+ });
185
+
186
+ canvas.addEventListener('mouseleave', () => {
187
+ isDragging = false;
188
+ canvas.style.cursor = 'grab';
189
+ });
190
+
191
+ img.src = imageUrl;
192
+ }
193
+ </script>
194
+ </body>
195
+ </html>
viewer.html ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Visor Panorámico - {{ filename }}</title>
7
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
8
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
9
+ <style>
10
+ body {
11
+ margin: 0;
12
+ padding: 0;
13
+ background: #000;
14
+ overflow: hidden;
15
+ }
16
+ .viewer-container {
17
+ position: relative;
18
+ width: 100vw;
19
+ height: 100vh;
20
+ }
21
+ .panorama-canvas {
22
+ display: block;
23
+ cursor: grab;
24
+ }
25
+ .panorama-canvas:active {
26
+ cursor: grabbing;
27
+ }
28
+ .controls {
29
+ position: absolute;
30
+ top: 20px;
31
+ left: 20px;
32
+ z-index: 1000;
33
+ display: flex;
34
+ gap: 10px;
35
+ }
36
+ .control-btn {
37
+ background: rgba(0,0,0,0.7);
38
+ border: none;
39
+ color: white;
40
+ padding: 10px 15px;
41
+ border-radius: 8px;
42
+ cursor: pointer;
43
+ transition: background 0.3s;
44
+ }
45
+ .control-btn:hover {
46
+ background: rgba(0,0,0,0.9);
47
+ }
48
+ .info-panel {
49
+ position: absolute;
50
+ bottom: 20px;
51
+ left: 20px;
52
+ right: 20px;
53
+ background: rgba(0,0,0,0.8);
54
+ color: white;
55
+ padding: 15px;
56
+ border-radius: 8px;
57
+ display: flex;
58
+ justify-content: space-between;
59
+ align-items: center;
60
+ }
61
+ .loading {
62
+ position: absolute;
63
+ top: 50%;
64
+ left: 50%;
65
+ transform: translate(-50%, -50%);
66
+ color: white;
67
+ text-align: center;
68
+ }
69
+ .zoom-controls {
70
+ position: absolute;
71
+ top: 20px;
72
+ right: 20px;
73
+ display: flex;
74
+ flex-direction: column;
75
+ gap: 5px;
76
+ }
77
+ .zoom-btn {
78
+ background: rgba(0,0,0,0.7);
79
+ border: none;
80
+ color: white;
81
+ width: 40px;
82
+ height: 40px;
83
+ border-radius: 50%;
84
+ cursor: pointer;
85
+ display: flex;
86
+ align-items: center;
87
+ justify-content: center;
88
+ }
89
+ .zoom-btn:hover {
90
+ background: rgba(0,0,0,0.9);
91
+ }
92
+ </style>
93
+ </head>
94
+ <body>
95
+ <div class="viewer-container">
96
+ <div class="loading" id="loading">
97
+ <div class="spinner-border" role="status">
98
+ <span class="visually-hidden">Cargando...</span>
99
+ </div>
100
+ <p class="mt-2">Cargando panorama...</p>
101
+ </div>
102
+
103
+ <canvas id="panoramaCanvas" class="panorama-canvas" style="display: none;"></canvas>
104
+
105
+ <div class="controls">
106
+ <button class="control-btn" onclick="window.close()" title="Cerrar">
107
+ <i class="bi bi-x-lg"></i>
108
+ </button>
109
+ <button class="control-btn" onclick="window.history.back()" title="Volver">
110
+ <i class="bi bi-arrow-left"></i>
111
+ </button>
112
+ <button class="control-btn" onclick="toggleFullscreen()" title="Pantalla completa">
113
+ <i class="bi bi-fullscreen"></i>
114
+ </button>
115
+ <button class="control-btn" onclick="resetView()" title="Reiniciar vista">
116
+ <i class="bi bi-arrow-clockwise"></i>
117
+ </button>
118
+ </div>
119
+
120
+ <div class="zoom-controls">
121
+ <button class="zoom-btn" onclick="zoomIn()" title="Acercar">
122
+ <i class="bi bi-plus"></i>
123
+ </button>
124
+ <button class="zoom-btn" onclick="zoomOut()" title="Alejar">
125
+ <i class="bi bi-dash"></i>
126
+ </button>
127
+ </div>
128
+
129
+ <div class="info-panel">
130
+ <div>
131
+ <strong>{{ filename }}</strong>
132
+ <br>
133
+ <small>Arrastra para rotar • Rueda del mouse para zoom</small>
134
+ </div>
135
+ <div>
136
+ <button class="btn btn-sm btn-outline-light" onclick="downloadImage()">
137
+ <i class="bi bi-download"></i> Descargar
138
+ </button>
139
+ </div>
140
+ </div>
141
+ </div>
142
+
143
+ <script>
144
+ const canvas = document.getElementById('panoramaCanvas');
145
+ const ctx = canvas.getContext('2d');
146
+ const loading = document.getElementById('loading');
147
+
148
+ let img = new Image();
149
+ let isDragging = false;
150
+ let lastX = 0;
151
+ let lastY = 0;
152
+ let offsetX = 0;
153
+ let offsetY = 0;
154
+ let zoom = 1;
155
+ let minZoom = 0.5;
156
+ let maxZoom = 3;
157
+
158
+ // Configurar canvas
159
+ function resizeCanvas() {
160
+ canvas.width = window.innerWidth;
161
+ canvas.height = window.innerHeight;
162
+ if (img.complete) {
163
+ drawPanorama();
164
+ }
165
+ }
166
+
167
+ // Cargar imagen
168
+ img.onload = function() {
169
+ loading.style.display = 'none';
170
+ canvas.style.display = 'block';
171
+ resizeCanvas();
172
+
173
+ // Calcular zoom inicial para ajustar la imagen
174
+ const scaleX = canvas.width / img.width;
175
+ const scaleY = canvas.height / img.height;
176
+ zoom = Math.max(scaleX, scaleY);
177
+ minZoom = zoom * 0.5;
178
+ maxZoom = zoom * 3;
179
+
180
+ drawPanorama();
181
+ };
182
+
183
+ img.onerror = function() {
184
+ loading.innerHTML = '<p>Error al cargar la imagen</p>';
185
+ };
186
+
187
+ // Dibujar panorama
188
+ function drawPanorama() {
189
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
190
+
191
+ const scaledWidth = img.width * zoom;
192
+ const scaledHeight = img.height * zoom;
193
+
194
+ // Centrar verticalmente
195
+ const centerY = (canvas.height - scaledHeight) / 2 + offsetY;
196
+
197
+ // Dibujar imagen principal
198
+ ctx.drawImage(img, offsetX, centerY, scaledWidth, scaledHeight);
199
+
200
+ // Repetir horizontalmente para efecto cilíndrico
201
+ if (scaledWidth + offsetX < canvas.width) {
202
+ ctx.drawImage(img, offsetX + scaledWidth, centerY, scaledWidth, scaledHeight);
203
+ }
204
+ if (offsetX > 0) {
205
+ ctx.drawImage(img, offsetX - scaledWidth, centerY, scaledWidth, scaledHeight);
206
+ }
207
+
208
+ // Limitar desplazamiento vertical
209
+ const maxOffsetY = Math.max(0, (scaledHeight - canvas.height) / 2);
210
+ offsetY = Math.max(-maxOffsetY, Math.min(maxOffsetY, offsetY));
211
+ }
212
+
213
+ // Eventos de mouse
214
+ canvas.addEventListener('mousedown', (e) => {
215
+ isDragging = true;
216
+ lastX = e.clientX;
217
+ lastY = e.clientY;
218
+ });
219
+
220
+ canvas.addEventListener('mousemove', (e) => {
221
+ if (isDragging) {
222
+ const deltaX = e.clientX - lastX;
223
+ const deltaY = e.clientY - lastY;
224
+
225
+ offsetX += deltaX;
226
+ offsetY += deltaY;
227
+
228
+ // Mantener offset X en rango válido para efecto cilíndrico
229
+ const scaledWidth = img.width * zoom;
230
+ if (offsetX > scaledWidth) offsetX -= scaledWidth;
231
+ if (offsetX < -scaledWidth) offsetX += scaledWidth;
232
+
233
+ drawPanorama();
234
+ lastX = e.clientX;
235
+ lastY = e.clientY;
236
+ }
237
+ });
238
+
239
+ canvas.addEventListener('mouseup', () => {
240
+ isDragging = false;
241
+ });
242
+
243
+ canvas.addEventListener('mouseleave', () => {
244
+ isDragging = false;
245
+ });
246
+
247
+ // Zoom con rueda del mouse
248
+ canvas.addEventListener('wheel', (e) => {
249
+ e.preventDefault();
250
+ const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
251
+ const newZoom = zoom * zoomFactor;
252
+
253
+ if (newZoom >= minZoom && newZoom <= maxZoom) {
254
+ zoom = newZoom;
255
+ drawPanorama();
256
+ }
257
+ });
258
+
259
+ // Eventos de teclado
260
+ document.addEventListener('keydown', (e) => {
261
+ switch(e.key) {
262
+ case 'Escape':
263
+ if (document.fullscreenElement) {
264
+ document.exitFullscreen();
265
+ }
266
+ break;
267
+ case 'f':
268
+ case 'F':
269
+ toggleFullscreen();
270
+ break;
271
+ case 'r':
272
+ case 'R':
273
+ resetView();
274
+ break;
275
+ }
276
+ });
277
+
278
+ // Funciones de control
279
+ function zoomIn() {
280
+ const newZoom = zoom * 1.2;
281
+ if (newZoom <= maxZoom) {
282
+ zoom = newZoom;
283
+ drawPanorama();
284
+ }
285
+ }
286
+
287
+ function zoomOut() {
288
+ const newZoom = zoom * 0.8;
289
+ if (newZoom >= minZoom) {
290
+ zoom = newZoom;
291
+ drawPanorama();
292
+ }
293
+ }
294
+
295
+ function resetView() {
296
+ offsetX = 0;
297
+ offsetY = 0;
298
+ const scaleX = canvas.width / img.width;
299
+ const scaleY = canvas.height / img.height;
300
+ zoom = Math.max(scaleX, scaleY);
301
+ drawPanorama();
302
+ }
303
+
304
+ function toggleFullscreen() {
305
+ if (!document.fullscreenElement) {
306
+ document.documentElement.requestFullscreen();
307
+ } else {
308
+ document.exitFullscreen();
309
+ }
310
+ }
311
+
312
+ function downloadImage() {
313
+ const link = document.createElement('a');
314
+ link.download = '{{ filename }}';
315
+ link.href = '{{ url_for("static", filename=image_path) }}';
316
+ link.click();
317
+ }
318
+
319
+ // Redimensionar canvas cuando cambie el tamaño de ventana
320
+ window.addEventListener('resize', resizeCanvas);
321
+
322
+ // Cargar imagen
323
+ img.src = '{{ url_for("static", filename=image_path) }}';
324
+ </script>
325
+ </body>
326
+ </html>