martes, 6 de octubre de 2026

Uso de la IA para editar vídeos de pádel

 Usando la IA para extraer vídeos cortos (highlights) de partidos de padel grabados


Ultimamente estoy jugando al pádel, y grabando los partidos con una GoPro pegada al cristal del fondo con una ventosa.

Eso produce vídeos de 1h u hora y media que luego subo a Youtube y comparto en privado con los demás jugadores del partido. 


Ejemplo de toma de la pista desde el fondo

Los vídeos se pueden ver enteros en Youtube, que actúa como repositorio, pero o te acuerdas perfectamente del momento en que hubo un intercambio largo o interesante, o casi nadie se va a ver una hora y media de padel de nuevo. 

Así que buscaba alguna aplicación o solución que aplicase la IA para sacar lo mejor de todo el vídeo sin tener que editarlo manualmente. 

Una opción es usar la web de https://court-replay.ai/ pero está limitado a 3 vídeos gratis por semana, y veremos en el futuro. Lo bueno es que extrae los clips de los intercambios (rallies) más largos, así como estadísticas. 

Pero también encontré esto en github 
https://github.com/Crezy-haker/videocutterAI

Tiene varios programas en python que analizan el vídeo y te dan 5 clips interesantes. 

Monté un contenedor dentro de mi proxmox, con 12 CPUs, 32 Gb de ram para que no tenga problemas al editar video en HD, y 25 Gb de disco para almacenar los vídeos temporalmente. 

Una vez dentro del contenedor, basado en debian 13, ejecuto los comandos habituales: 

apt install git
apt install python3-pip
pip install -r requirements.txt --break-system-packages

python3 -m pip install flask --break-system-packages

apt install -y ffmpeg git" + python3 -m pip install openai-whisper --break-system-packages


Con todo  esto, hemos instalado Python para poder ejecutar el código, flask para generar las páginas web de información, ffmpeg para poder editar video, Whisper para transcribir el audio del mismo. 

Lo de break system packages es para que se cargue en el contenedor sin tener que instalar un entorno temporal de trabajo. 


lanzo el comando "python3 app.py" y se muetra la app, pero sólo es accesible desde 127.0.0.1, así que no puedo entrar porque el contenedor no tiene GUI 


Me toca modificar esta línea de app.py mediante "nano app.py"

if __name__ == '__main__':

    app.run(host='0.0.0.0', port=5000, debug=True)

Al poner 0.0.0.0 se da permiso para que otro equipo en la misma red de mi contenedor acceda. 


La web carga, pero me falla al cargar archivos 



Con ayuda de Gemini detecto que me fallan fuentes, así que desde CLI ejecuto: 

apt install -y fonts-dejavu-core fonts-dejavu


En la siguiente prueba el programa dice que el uso de Google Api está obsoleta. Así que modifico app.py y video_processor.py para que utilice mis propias llm en mi servidor de ollama. 

El flujo de trabajo de la app es el siguiente: 

Carga el video en /uploads

Usa Whisper para analizar el audio y transcribirlo

Le pasa el audio transcrito a una llm que tengas (antes se lo pasaba a gemini, pero eso ya no funciona ni dándole tu API 

Le pide a la LLM que analice el vídeo y saque 5clips de jugadas destacadas 

Luego con los tiempos de los vídeos seleccionados, corta los mismos incluyendo el sonido y genera una web para poder verlos y descargarlos





Pego aquí el código de app.py final y de video_processor.py (los puedes pegar dentro de nano) 

app.py

from flask import Flask, render_template, request, redirect, url_for, flash, send_from_directory

import os

from werkzeug.utils import secure_filename

import sqlite3

from datetime import datetime

from video_processor import VideoProcessor

import threading

from dotenv import load_dotenv


def adapt_datetime(ts):

    return ts.isoformat()


def convert_datetime(val):

    return datetime.fromisoformat(val)


# Register the adapter and converter

sqlite3.register_adapter(datetime, adapt_datetime)

sqlite3.register_converter("datetime", convert_datetime)


load_dotenv()


app = Flask(__name__)

app.secret_key = os.getenv('FLASK_SECRET_KEY', 'dev')  # Change this in production

app.config['UPLOAD_FOLDER'] = os.path.abspath('uploads')

app.config['CLIPS_FOLDER'] = os.path.abspath('clips')

app.config['TRANSCRIPTS_FOLDER'] = os.path.abspath('transcripts')

app.config['DATABASE'] = os.path.abspath('videos.db')

app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024 * 1024  # 5000MB max file size

app.config['ALLOWED_EXTENSIONS'] = {'mp4', 'avi', 'mov', 'mkv'}


# Create required directories if they don't exist

os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)

os.makedirs(app.config['CLIPS_FOLDER'], exist_ok=True)

os.makedirs(app.config['TRANSCRIPTS_FOLDER'], exist_ok=True)


# Database initialization

def init_db():

    conn = sqlite3.connect(app.config['DATABASE'], detect_types=sqlite3.PARSE_DECLTYPES)

    c = conn.cursor()

    c.execute('''CREATE TABLE IF NOT EXISTS videos

                 (id INTEGER PRIMARY KEY AUTOINCREMENT,

                  filename TEXT,

                  upload_date DATETIME,

                  status TEXT)''')

    c.execute('''CREATE TABLE IF NOT EXISTS clips

                 (id INTEGER PRIMARY KEY AUTOINCREMENT,

                  video_id INTEGER,

                  clip_path TEXT,

                  start_time REAL,

                  end_time REAL,

                  description TEXT,

                  FOREIGN KEY(video_id) REFERENCES videos(id))''')

    conn.commit()

    conn.close()


init_db()


@app.route('/')

def index():

    return render_template('upload.html')


# Add after init_db()

@app.route('/upload', methods=['POST'])

def upload_video():

    if 'video' not in request.files:

        return redirect(url_for('index'))

    

    file = request.files['video']

    if file.filename == '':

        return redirect(url_for('index'))

    

    if not allowed_file(file.filename):

        flash('File type not allowed')

        return redirect(url_for('index'))

    

    filename = secure_filename(file.filename)

    filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)

    file.save(filepath)

      # Store in database

    conn = sqlite3.connect(app.config['DATABASE'], detect_types=sqlite3.PARSE_DECLTYPES)

    c = conn.cursor()

    c.execute('INSERT INTO videos (filename, upload_date, status) VALUES (?, ?, ?)',

              (filename, datetime.now(), 'uploaded'))

    video_id = c.lastrowid

    conn.commit()

    conn.close()

    

    # Start background processing

    # processor = VideoProcessor()

    threading.Thread(target=process_video_background, args=(video_id, filepath)).start()

    

    return redirect(url_for('status', video_id=video_id))


def allowed_file(filename):

    return '.' in filename and filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS']


def process_video_background(video_id, filepath):

    conn = sqlite3.connect(app.config['DATABASE'], detect_types=sqlite3.PARSE_DECLTYPES)

    c = conn.cursor()

    

    def update_status(status):

        print(f"[STATUS] {status}", flush=True)

        c.execute('UPDATE videos SET status=? WHERE id=?', (status, video_id))

        conn.commit()

    

    try:

        update_status('initializing')

        

        try:

            print("[INFO] Cargando VideoProcessor...", flush=True)

            processor = VideoProcessor()

            print("[INFO] VideoProcessor listo.", flush=True)

        except Exception as e:

            print(f"[ERROR] Error al instanciar VideoProcessor: {e}", flush=True)

            update_status(f'error: Failed to initialize video processor: {str(e)}')

            return

            

        update_status('transcribing')

        

        try:

            print(f"[INFO] Procesando video {filepath}...", flush=True)

            clips = processor.process_video(filepath)

            print(f"[INFO] Clips devueltos: {len(clips)}", flush=True)

        except Exception as e:

            print(f"[ERROR] Error en process_video: {e}", flush=True)

            update_status(f'error: Failed to process video: {str(e)}')

            return

        

        # Store clips in database

        for clip in clips:

            try:

                c.execute('''INSERT INTO clips 

                           (video_id, clip_path, start_time, end_time, description)

                           VALUES (?, ?, ?, ?, ?)''',

                         (video_id, clip['path'], clip['start'], 

                          clip['end'], clip['description']))

            except Exception as e:

                update_status(f'error: Failed to save clip: {str(e)}')

                return

        

        update_status('processed')

        conn.commit()

        

    except Exception as e:

        update_status(f'error: Unexpected error: {str(e)}')

    finally:

        conn.close()


@app.route('/status/<int:video_id>')

def status(video_id):

    conn = sqlite3.connect(app.config['DATABASE'])

    c = conn.cursor()

    c.execute('SELECT v.status, v.filename FROM videos v WHERE v.id=?', (video_id,))

    result = c.fetchone()

    conn.close()

    

    if not result:

        return "Vídeo no encontrado", 404

    

    current_status, filename = result

    

    # Si ya terminó, redirigir de inmediato al dashboard

    if current_status == 'processed':

        return redirect(url_for('dashboard', video_id=video_id))

    

    # Si sigue procesando, mostrar pantalla que se autorefresca cada 3 segundos

    return f"""

    <!DOCTYPE html>

    <html>

    <head>

        <meta charset="utf-8">

        <title>Procesando {filename}</title>

        <meta http-equiv="refresh" content="3">

        <style>

            body {{ font-family: system-ui, sans-serif; display: flex; justify-content: center; align-items: center; height: 90vh; margin: 0; background: #0f172a; color: #f8fafc; }}

            .card {{ text-align: center; padding: 2rem; background: #1e293b; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.5); }}

            .spinner {{ margin: 1.5rem auto; width: 40px; height: 40px; border: 4px solid #334155; border-top-color: #38bdf8; border-radius: 50%; animation: spin 1s infinite linear; }}

            @keyframes spin {{ to {{ transform: rotate(360deg); }} }}

        </style>

    </head>

    <body>

        <div class="card">

            <h2>Analizando jugadas...</h2>

            <p><strong>Archivo:</strong> {filename}</p>

            <div class="spinner"></div>

            <p>Estado actual: <code>{current_status}</code></p>

            <small>La página se actualizará automáticamente al terminar.</small>

        </div>

    </body>

    </html>

    """


@app.route('/clips/<path:filename>')

def serve_clip(filename):

    return send_from_directory(app.config['CLIPS_FOLDER'], filename)


# Add after status route

@app.route('/dashboard/<int:video_id>')

def dashboard(video_id):

    conn = sqlite3.connect(app.config['DATABASE'])

    c = conn.cursor()

    

    # Get video info

    c.execute('SELECT * FROM videos WHERE id=?', (video_id,))

    video = c.fetchone()

    

    if not video:

        flash('Video not found', 'error')

        return redirect(url_for('index'))

    

    # Get clips

    c.execute('SELECT * FROM clips WHERE video_id=?', (video_id,))

    clips = c.fetchall()

    

    # Convert clip paths to URLs

    formatted_clips = []

    for clip in clips:

        clip_filename = os.path.basename(clip[2])

        clip_url = url_for('serve_clip', filename=clip_filename)

        formatted_clips.append({

            'id': clip[0],

            'video_id': clip[1],

            'url': clip_url,

            'start_time': clip[3],

            'end_time': clip[4],

            'description': clip[5]

        })

    

    conn.close()

    

    return render_template('dashboard.html', 

                          video=video,

                          clips=formatted_clips)


if __name__ == '__main__':

    app.run(host='0.0.0.0', port=5000, debug=True)


*******************************fin del fichero********************

video_processor.py

# filepath: c:\Users\Hariom kumar\Desktop\videocutterAI\video_processor.py

import os

import time

import whisper

import ffmpeg

import sqlite3

from datetime import datetime

from dotenv import load_dotenv

import ollama


load_dotenv()


class VideoProcessor:

    def __init__(self, clips_folder='clips'):

        # 1. Configuración de Ollama remoto

        self.ollama_host = os.getenv('OLLAMA_HOST', 'http://192.168.1.206:11434')

        self.ollama_model = os.getenv('OLLAMA_MODEL', 'llama3.2:3b')

        

        try:

            print(f"Conectando con Ollama en {self.ollama_host} (modelo: {self.ollama_model})...")

            self.ollama_client = ollama.Client(host=self.ollama_host)

        except Exception as e:

            raise Exception(f"No se pudo inicializar el cliente de Ollama: {str(e)}")


        # 2. Carga del modelo Whisper para CPU

        max_retries = 3

        retry_count = 0

        last_error = None

        

        while retry_count < max_retries:

            try:

                print(f"Cargando modelo Whisper (intento {retry_count + 1}/{max_retries})...")

                self.transcription_model = whisper.load_model(

                    name="tiny",

                    device="cpu",

                    download_root=os.path.join(os.path.expanduser("~"), ".cache", "whisper")

                )

                print("Modelo Whisper cargado en memoria.")

                break

            except Exception as e:

                last_error = str(e)

                retry_count += 1

                print(f"Error cargando Whisper: {str(e)}")

                if retry_count < max_retries:

                    print("Reintentando en 5 segundos...")

                    time.sleep(5)

        

        if retry_count == max_retries:

            raise Exception(f"Fallo al cargar Whisper tras {max_retries} intentos: {last_error}")


        self.clips_folder = clips_folder

        os.makedirs(self.clips_folder, exist_ok=True)

    

    def process_video(self, video_path):

        print("Iniciando transcripción y análisis temporal del vídeo...")

        transcript = self.transcribe_video(video_path)

        

        print("Analizando los puntos y momentos destacados con Ollama...")

        highlights = self.analyze_highlights(transcript)

        

        print(f"Generando clips recortados ({len(highlights)} detectados)...")

        clips = self.generate_clips(video_path, highlights)

        

        return clips

    

    def transcribe_video(self, video_path):

        options = {

            'fp16': False,

            'language': 'es',

            'task': 'transcribe',

            'beam_size': 1,

            'best_of': 1

        }

        result = self.transcription_model.transcribe(video_path, **options)

        

        # Extraemos marcas temporales y duración de actividad sonora/vocal

        structured_log = []

        for seg in result.get('segments', []):

            start = round(seg['start'], 1)

            end = round(seg['end'], 1)

            dur = round(end - start, 1)

            text = seg['text'].strip()

            mins = int(start // 60)

            secs = int(start % 60)

            timestamp_str = f"{mins:02d}:{secs:02d}"

            structured_log.append(f"[{timestamp_str}] (Duración: {dur}s) {text}")

            

        return "\n".join(structured_log)

    

    def analyze_highlights(self, transcript):

        if not transcript.strip():

            print("Aviso: Whisper no detectó audio ni actividad vocal suficiente.")

            return []


        prompt = f"""Eres un analista experto de partidos de pádel. A continuación tienes la lista cronológica de secuencias de juego con sus marcas de tiempo [MM:SS], duración del intercambio y audio/comentarios detectados:


{transcript}


TAREA:

Identifica los 5 puntos más largos, disputados o intensos del partido. Si un intercambio largo abarca varias marcas consecutivas, incluye el rango completo desde que empieza el saque hasta que termina el punto.


Devuelve ÚNICAMENTE los 5 puntos, uno por línea, siguiendo estrictamente este formato con tiempo de inicio y fin:

[MM:SS - MM:SS] Descripción breve del punto


Ejemplo de respuesta esperada:

[02:15 - 03:10] Punto largo de 55 segundos con peloteo continuo y remate final

[08:40 - 09:15] Peloteo disputado en la red con varias voleas y celebración

"""


        try:

            print(f"Enviando transcripción a {self.ollama_model} en {self.ollama_host}...", flush=True)

            response = self.ollama_client.chat(

                model=self.ollama_model,

                messages=[{'role': 'user', 'content': prompt}],

                options={'temperature': 0.2}

            )

            response_text = response['message']['content']

            print(f"Respuesta recibida de Ollama:\n{response_text}\n", flush=True)

            return self.parse_gemini_response(response_text)

        except Exception as e:

            print(f"Error consultando Ollama: {str(e)}")

            return []

    

    def parse_gemini_response(self, response_text):

        import re

        highlights = []

        lines = [line.strip() for line in response_text.split('\n') if line.strip()]

        

        for line in lines:

            # Buscar cualquier patrón de tiempo del tipo MM:SS o MM:SS - MM:SS / MM:SS a MM:SS

            # Ejemplos: [01:30 - 02:15], [14:27 a 14:49], (14:27 - 14:49) o simplemente [01:30]

            time_matches = re.findall(r'(\d{1,2}:\d{2})', line)

            

            if not time_matches:

                continue


            try:

                # Limpiar la descripción quitando corchetes y marcas de tiempo

                desc = re.sub(r'\[.*?\]|\(.*?\)', '', line).strip()

                if not desc:

                    desc = "Punto destacado del partido"


                # CASO 1: Ollama devolvió dos marcas de tiempo (Inicio y Fin)

                if len(time_matches) >= 2:

                    m1, s1 = map(float, time_matches[0].split(':'))

                    m2, s2 = map(float, time_matches[1].split(':'))

                    start_time = m1 * 60 + s1

                    end_time = m2 * 60 + s2


                # CASO 2: Ollama solo dio una marca de tiempo

                else:

                    m, s = map(float, time_matches[0].split(':'))

                    center_time = m * 60 + s

                    

                    # Intentar leer si en el texto puso los segundos reales (ej: "de 25.9 segundos")

                    sec_match = re.search(r'(\d+(?:\.\d+)?)\s*(?:segundos|s)', line, re.IGNORECASE)

                    if sec_match:

                        dur = float(sec_match.group(1))

                        start_time = max(0, center_time - (dur / 2))

                        end_time = center_time + (dur / 2)

                    else:

                        # Si no especifica, asignar un punto estándar de 30 segundos

                        start_time = max(0, center_time - 15)

                        end_time = center_time + 15


                # Aplicar margen de seguridad (3 segundos antes del saque y 3 tras el punto)

                real_start = max(0, start_time - 3)

                real_end = end_time + 3


                # REGLA DE ORO: En pádel ningún punto destacado dura menos de 15 segundos

                # Si la duración calculada es inferior a 15s, ampliar a 25s

                if (real_end - real_start) < 15:

                    real_end = real_start + 25


                highlights.append({

                    'start': real_start,

                    'end': real_end,

                    'description': desc

                })


            except Exception as e:

                print(f"Error parseando línea: {line} -> {str(e)}")

                continue


        return highlights[:5]

    

    def generate_clips(self, video_path, highlights):

        if not highlights:

            print("No se encontraron jugadas destacadas para recortar.")

            return []


        font_path = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'

        use_font = os.path.exists(font_path)


        clips = []

        for i, highlight in enumerate(highlights, 1):

            try:

                output_path = os.path.join(self.clips_folder, f"clip_{i}_{datetime.now().timestamp():.0f}.mp4")

                print(f"Generando clip {i}/{len(highlights)}: {highlight['description'][:50]}...")

                

                start = float(highlight['start'])

                duration = float(highlight['end'] - highlight['start'])

                

                # Leemos directamente del vídeo original aplicando el seek

                in_file = ffmpeg.input(video_path, ss=start, t=duration)

                

                # Procesamos el stream de vídeo (con o sin rótulo)

                if use_font:

                    subtitle_text = highlight.get('description', f'Punto {i}')

                    v_stream = in_file.video.drawtext(

                        text=subtitle_text,

                        fontfile=font_path,

                        fontsize=22,

                        fontcolor='white',

                        borderw=2,

                        x='(w-text_w)/2',

                        y='h-50'

                    )

                else:

                    v_stream = in_file.video


                # Mapeamos explícitamente el stream de audio

                a_stream = in_file.audio

                

                # Renderizamos en un único paso con codecs estándar para navegadores

                (

                    ffmpeg.output(

                        v_stream,

                        a_stream,

                        output_path,

                        vcodec='libx264',

                        acodec='aac',

                        audio_bitrate='192k',

                        ac=2,                      # Forzar 2 canales estéreo

                        pix_fmt='yuv420p',         # Máxima compatibilidad HTML5

                        preset='fast',

                        movflags='+faststart'      # Permite reproducción web sin descargar el archivo completo

                    )

                    .overwrite_output()

                    .run(quiet=True)

                )

                

                clips.append({

                    'path': output_path,

                    'start': highlight['start'],

                    'end': highlight['end'],

                    'description': highlight['description']

                })

                

            except Exception as e:

                print(f"Error procesando el clip {i}: {str(e)}")

                continue

                

        return clips


*******************************Fin del fichero ************************


Nota: Ojo que en la línea 

self.ollama_host = os.getenv('OLLAMA_HOST', 'http://192.168.1.206:11434')

Yo he incluido a fuego la dirección de m servidor de ollama con varios llm, en este caso suando llama3.2:3b 

En tu caso tendrás que redirigirlo a tu servidor de ollama, que es quien realiza el trabajo de analizar lo transcrito. 


Espero que esta modificación del proyecto os pueda servir para editar fácilmente vuestros vídeos de pádel. 

Modificando el prompt se podría editar para fútbol u otros deportes, incluso para detectar palabras específicas dichas durante el partido. 


Saludos a todos



 

 

martes, 11 de marzo de 2025

Volumio, ¿qué es?

 

Hola de nuevo

Volumio es un fabricante de dispositivos reproductores de música, pero que además nos ofrece gratis su software reproductor para instalarlo en una Pi que tengamos disponible. 



En mi caso descargo el fichero para Pi, se descomprime, y mediante "pi imager" u otro software similar, quemamos la imagen .img en una tarjeta micro sd. 

La ponemos en la pi, arrancamos y nos sale un login que suele ser volumio con password volumio. 

Para poder configurar cosas, recomiendo conectar un cable de red.

Desde otro Pc o equipo de nuestra red, nos conectamos con un navegador a "volumio.local" que será el nombre del dispositivo. Si no conseguimos verlo, podemos meternos en nuestro router para ver qué equipos detecta en la red, o usar una app de android como "network analyser" que lista todos los equipos conectados. 


Una vez dentro, nos damos de alta en Volumio con la cuenta gratuita. 

Y ya podemos buscar nuestra música en la NAS, si la tenemos, o añadir radios en línea como Calm Radio y de Soundcloud. 

También podemos añadir complementos o plugins disponibles, como vúmetros, o ecualizadores. 



En mi caso, desde su salida estereo de 3,5 mm, lo saco a un amplificador.

Desde la app de Android, si activas wifi puedes controlar el reproductor, subir volumen, añadir fuentes, pausar, apagar. 


Pero en mi caso, voy a añadir un extra. Quiero conectarle una pantalla de pi de 3,2 pulgadas LCD con táctil resistiva (no capacitiva) que no usaba para nada. Resolución de 320x240 píxeles





Para poder instalar todo lo necesario de la pantalla, tenemos que ir a volumio.local/dev y activar ssh. 



Y desde la web de Volumio (volumio.local) instalamos el complemento (plugin) Touch Display (está dentro de User interface) 

Detalles


En un PC dentro la misma red abrimos un CMD y tecleamos ssh volumio@volumio.local

Nos pide la contraseña (por defecto es volumio), y entramos. 

Lanzamos este comando para descargar ficheros necesarios para el funcionamiento de la pantalla = "wget https://github.com/waveshare/LCD-show/raw/master/waveshare32b-overlay.dtb" 

Lo copiamos sin las comillas, y botón derecho en la ventana MSDOS, pulsamos enter. 

sudo cp waveshare32b-overlay.dtb /boot/overlays/waveshare32b-overlay.dtbo

Esto copia el fichero recién descargado a su ubicación correcta

Abrimos el fichero userconfig.txt de la carpeta boot (arranque) 

nano /boot/userconfig.txt

Y añadimos estas líneas 

dtparam=spi=on

dtoverlay=waveshare32b-overlay

Guardamos el fichero con Ctrl+ X y darle a "y" para decir que sí. 

Descargamos “99-calibration.conf-32” que usaremos para la calibración.

wget https://raw.githubusercontent.com/waveshare/LCD-show/master/etc/X11/xorg.conf.d/99-calibration.conf-32

Creamos una carpeta en “/etc/X11/xorg.conf.d” y copiamos el fichero que acabamos de descargar 

sudo mkdir -p /etc/X11/xorg.conf.d

sudo cp 99-calibration.conf-32 /etc/X11/xorg.conf.d/99-calibration.conf

Descargamos el fichero “99-fbturbo.conf” por si no estuviese en la carpeta “/usr/share/X11/xorg.conf.d”:

sudo wget https://raw.githubusercontent.com/waveshare/LCD-show/master/usr/share/X11/xorg.conf.d/99-fbturbo.conf -P /usr/share/X11/xorg.conf.d

Abrimos “/usr/share/X11/xorg.conf.d/99-fbturbo.conf” para editarlo 

sudo nano /usr/share/X11/xorg.conf.d/99-fbturbo.conf

donde cambiamos Driver "fbturbo" a Driver "fbdev"

Y si tienes Option "fbdev" "/dev/fb0", poner Option "fbdev" "/dev/fb1"

Salvamos con Ctrl-X.

Para que funcione la parte táctil ejecutamos 

sudo apt-get install xserver-xorg-input-evdev

y luego 

sudo cp /usr/share/X11/xorg.conf.d/10-evdev.conf /usr/share/X11/xorg.conf.d/45-evdev.conf


Para calibrar la pantalla, ponemos:

sudo apt-get install -y xinput-calibrator 

Lo ejectuamos con: DISPLAY=:0 xinput_calibrator

Ojo que en algunas web aparece el comando con 2 ceros y no funciona. 

Si funciona tenemos esta imagen: 


Pinchamos en cada cruceta roja, y nos dará un resultado de 4 números.

Los metemos en el fichero adecuado:

sudo nano /etc/X11/xorg.conf.d/99-calibration.conf

  


Salimos con Ctrl + X y yes, reiniciamos y la pantalla debe funcionar ahora.


POSIBLES ERRORES Y SOLUCIONES: 

Hace poco se estropeó la tarjeta microSD y volví a instalar Volumio en la versión 3.832, y cuando llego a calibrar la pantalla táctil, me sale siempre un error "unable to connect to X server"

Y era que en esta versión de Volumio, no hay X, servidor X o entorno gráfico, y por eso me da error. Le estoy pidiendo que muestre una aplicación en la pantalla, y me indica que no puede porque no encuentra entorno gráfico qué usar. 

La solución es ir a la web de volumio.local y en ajustes, complementos, User interface, instalar el complemento "Touch Display", que añade lo necesario, como xorg o X (entorno gráfico)



Tras instalarlo, puedo funcionar con el comando "DISPLAY=:0 xinput_calibrator" y el resto de pasos. 


Si tienes errores del tipo Failed to open "alsa" (alsa); Failed to open ALSA device "volumio": Invalid argument

Lo más probable es que el plugin puppymeter esté dando problemas, y tengas que cambiar su configuración o desactivarlo. 




sudo amixer cset numid=3 1


Fuentes: https://community.volumio.com/t/display-raspberry-pi-3-2inch-lcd-320x240-resistive-touch-screen-9201/52861


domingo, 9 de febrero de 2025

Reparación de monitor Lenovo, de nuevo los condensadores al ataque

 Reciclando material en lugar de tirarlo


De nuevo cae en mis manos un par de monitores que van para la basura, porque se encienden, pero la iluminación de la pantalla está oscurecida, y sin brillo no se puede ver bien la imagen. 


Intuimos que algo puede haberse fundido, así que abrimos el monitor, y resulta que tras soltar los clips que sujetan la tapa posterior, tenemos 2 placas. Una es la de energía, que convierte los 220v AC en la corriente continua que usa el monitor, luego la placa de control de vídeo, que toma las señales DVI o Display port y las pasa a la pantalla, que ocupa todo el resto. 

Si miramos la placa de alimentación:



Podemos apreciar arriba a la izquierda que donde está la R3 que se ha quemado bien. Cuando esa resistencia murió, los 2 condensadores de arriba no pudieron soportar la energía y reventaron.

Lo que estáis viendo es la placa donante. Hemos desoldado los 2 condensadores idénticos de la derecha arriba, y los hemos puesto en lugar de los que reventaron. Y la R1 que es igual que la R3, la hemos puesto en la R3 de otra placa. 

Vamos, que de 2 placas muertas, hemos recuperado una. 

Y si compramos 2 condensadores de 220 uf y 35V, más una resistencia de 10K ohm, podremos tener los 2 monitores rotos arreglados. 

Aquí tenemos un detalle de los 2 condensadores y la resistencia




Como he indicado, tras sustituir los 2 condensadores y la resistencia, la placa queda así:


Volvemos a montar la fuente y la tapa trasera, y el monitor luce perfecto. 

Vista lateral de la placa reparada. 




Sólo hemos necesitado piezas donantes, o comprar los 2 condensadores y un soldador (mejor de precisión) y estaño. 

Os animo a lanzaros a este tipo de recuperaciones fáciles. 

Saludos

domingo, 26 de enero de 2025

Utilizar una raspberry pi como visualizador de página web o información (totem o kiosk)

 Configurar pi para un visualizador de páginas informativas


Información sacada de la web https://reelyactive.github.io/diy/pi-kiosk/ así como de 
https://fleetstack.io/blog/raspberry-pi-kiosk-tutorial

Existen más páginas o webs que ofrecen esto, incluso existe una distribución de pi imager (FullpageOS) pero no he conseguido que funcione bien ni muestre alguna web, a pesar de configurar su fichero en /boot/fullpageos.txt) 

Así que probamos esta otra solución, instalamos Pi Os Lite de 64 bits en nuestra Pi (que sea superior a la v2)



Encendemos, buscamos la ip con nuestro router o una app de android como net analyzer scanner, y nos conectamos por ssh (lo tenemos que haber activado antes en las opciones de pi imager, así como a la wifi que se va a conectar) 




actualizamos la distro con:
sudo apt update && sudo apt upgrade -y




tras unos minutos, podemos seguir haciendo cosas

Nos toca instalar el navegador: 
sudo apt install --no-install-recommends chromium-browser

Este también se lleva un rato. 

sudo apt install unclutter  (este elimina ver el cursor de ratón)

Nos hace falta instalar un entorno gráfico para poder ver cosas: 
sudo apt install raspberrypi-ui-mods

Añade 878 Mb de ficheros. 

Ahora modificamos el fichero de autoinicio: 
sudo nano /etc/xdg/lxsession/LXDE-pi/autostart

Y le pegamos este texto al final del archivo, modificando la web que será visitada al final.

@xset s noblank
@xset s off
@xset -dpms
@unclutter -idle 0.1 -root
@chromium-browser --noerrdialogs --disable-infobars --kiosk http://www.altabox.net

Abrimos este otro fichero para editarlo: 
sudo nano /etc/lightdm/lightdm.conf

Y en la sección [Seat:*] borramos el # para que sea válido y añadimos
xserver-command=X -s 0 dpms


Guadamos y salimos (ctrl + O y Ctrl + X) 

Y reiniciamos
el problema es que no hace autologin, así que toca lanzar el sudo raspi-config y en 
opciones de sistema, elegimos B4 (desktop autologin) 

Ahora se reinicia, y entra en la vista gráfica. La primera vez nos dice que recomienda cambiar el servidor de X al nuevo, labwc (Wayland)

Su autoinicio está en  /etc/xdg/labwc/autostart
Así que añado en ese fichero lo que teníamos del anterior y modifico para que la línea de lanzamiento sea: 
/usr/bin/lwrespawn /usr/bin/chromium-browser --app=http://url_deseada.com --kiosk --start-fullscreen --start-maximized --fast --fast-start --no-sandbox --no-first-run --noerrdialogs --disable-translate --disable-notifications --disable-infobars --disable-pinch --disable-features=TranslateUI --disk-cache-dir=/dev/null --ozone-platform=wayland --enable-features=OverlayScrollbar --overscroll-history-navigation=0 &

Sería algo como esto: 





Probamos a reiniciar...FUNCIONA

Ya tenemos nuestro kiosko navegador funcionando en raspberry os lite en 2025


Nota: el comando para saber qué servidor X está en uso es (desde CMD) 

loginctl show-session 1 | grep "Desktop" 
Que devuelve por ejemplo = "Desktop=rpd-labwc"