diff --git a/bin/.local/bin/colmap-amd b/bin/.local/bin/colmap-amd new file mode 100755 index 000000000..096a8ced9 --- /dev/null +++ b/bin/.local/bin/colmap-amd @@ -0,0 +1,10 @@ +#!/bin/sh + +# Évite un rendu logiciel Mesa accidentel. +unset LIBGL_ALWAYS_SOFTWARE +unset MESA_LOADER_DRIVER_OVERRIDE + +# Utilise Xwayland/GLX pour le contexte OpenGL de SIFTGPU. +export QT_QPA_PLATFORM=xcb + +exec colmap "$@" diff --git a/bin/.local/bin/firefox-scribd b/bin/.local/bin/firefox-scribd new file mode 100755 index 000000000..7beb52e70 --- /dev/null +++ b/bin/.local/bin/firefox-scribd @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly PROFILE="$HOME/.mozilla/firefox-scribd" +readonly URL="${1:-https://fr.scribd.com/}" + +case "$URL" in + http://scribd.com/*|https://scribd.com/*|\ + http://*.scribd.com/*|https://*.scribd.com/*) + ;; + *) + notify-send \ + "Firefox Scribd" \ + "URL refusée : ce lanceur accepte uniquement scribd.com" + exit 2 + ;; +esac + +mkdir -p "$PROFILE" + +exec systemd-run \ + --user \ + --scope \ + --quiet \ + --unit="firefox-scribd-$(date +%s)-$$" \ + --slice=app-graphical.slice \ + --property=MemoryMax=8G \ + --property=MemorySwapMax=0 \ + /usr/bin/firefox \ + --no-remote \ + --profile "$PROFILE" \ + --new-window "$URL" diff --git a/bin/.local/bin/llama-cli b/bin/.local/bin/llama-cli deleted file mode 120000 index a8d98a46d..000000000 --- a/bin/.local/bin/llama-cli +++ /dev/null @@ -1 +0,0 @@ -/home/fy59/.local/src/llama.cpp/build/bin/llama-cli \ No newline at end of file diff --git a/bin/.local/bin/llama-server b/bin/.local/bin/llama-server deleted file mode 120000 index d100fa486..000000000 --- a/bin/.local/bin/llama-server +++ /dev/null @@ -1 +0,0 @@ -/home/fy59/.local/src/llama.cpp/build/bin/llama-server \ No newline at end of file diff --git a/bin/.local/bin/opencode-away b/bin/.local/bin/opencode-away new file mode 100755 index 000000000..5eb24f617 --- /dev/null +++ b/bin/.local/bin/opencode-away @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -u + +usage() { + printf '%s\n' 'usage: opencode-away [--check] [opencode arguments...]' +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || { + printf 'opencode-away: required command not found: %s\n' "$1" >&2 + return 1 + } +} + +if [[ ${1-} == --help ]]; then + usage + exit 0 +fi + +check_only=0 +if [[ ${1-} == --check ]]; then + check_only=1 + shift +fi + +require_command opencode || exit 127 +require_command systemd-inhibit || exit 127 +require_command git || exit 127 +require_command ps || exit 127 +require_command kill || exit 127 + +if [[ $(systemd-inhibit --help 2>&1) != *handle-lid-switch* ]]; then + printf '%s\n' 'opencode-away: systemd-inhibit lacks handle-lid-switch' >&2 + exit 3 +fi + +if ! git rev-parse --show-toplevel >/dev/null 2>&1; then + printf '%s\n' 'opencode-away: run this command inside a Git worktree' >&2 + exit 2 +fi + +if ((check_only)); then + printf '%s\n' 'OpenCode away mode check: OK' + printf 'repository: %s\n' "$(git rev-parse --show-toplevel)" + printf 'opencode: %s\n' "$(command -v opencode)" + printf 'systemd-inhibit: %s\n' "$(command -v systemd-inhibit)" + exit 0 +fi + +declare -a suspended_pids=() +cleanup_done=0 + +restore_swayidle() { + local pid + ((cleanup_done)) && return 0 + cleanup_done=1 + for pid in "${suspended_pids[@]}"; do + if kill -0 "$pid" 2>/dev/null && + [[ $(ps -o comm= -p "$pid" 2>/dev/null) == swayidle ]]; then + kill -CONT "$pid" 2>/dev/null || true + fi + done + if ((${#suspended_pids[@]})); then + printf '%s\n' 'swayidle restored' + fi +} + +trap restore_swayidle EXIT + +while read -r pid state; do + [[ $pid =~ ^[0-9]+$ ]] || continue + [[ $state == T* ]] && continue + if kill -STOP "$pid" 2>/dev/null; then + suspended_pids+=("$pid") + fi +done < <(ps -C swayidle -o pid=,stat= 2>/dev/null) + +printf '%s\n' 'OpenCode away mode' +printf '%s\n' 'sleep inhibited' +printf '%s\n' 'lid switch inhibited' +if ((${#suspended_pids[@]})); then + printf 'swayidle suspended:' + printf ' %s' "${suspended_pids[@]}" + printf '\n' +else + printf '%s\n' 'swayidle suspended: none active' +fi + +export LARDON_OPENCODE_LONG_RUN=1 + +if [[ -n ${OPENCODE_AWAY_COMMAND-} ]]; then + child_command=(bash -c "$OPENCODE_AWAY_COMMAND") +else + child_command=(opencode --auto "$@") +fi + +systemd-inhibit \ + --what=sleep:idle:handle-lid-switch \ + --who='OpenCode Long Run' \ + --why='Autonomous Lardon3D development ticket' \ + --mode=block \ + "${child_command[@]}" + +child_status=$? + +restore_swayidle + +exit "$child_status" diff --git a/bin/.local/bin/pi-dev b/bin/.local/bin/pi-dev new file mode 100755 index 000000000..9fd55faec --- /dev/null +++ b/bin/.local/bin/pi-dev @@ -0,0 +1,91 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +SWAYIDLE_MODE="none" +declare -a SWAYIDLE_CMD=() + +cleanup() { + rc=$? + + trap - EXIT INT TERM HUP + + printf '\n[pi-dev] Arrêt de la session...\n' + + # Restaurer swayidle exactement selon son mode d'origine. + case "$SWAYIDLE_MODE" in + systemd) + printf '[pi-dev] Redémarrage de swayidle.service...\n' + systemctl --user start swayidle.service || true + ;; + + process) + if ((${#SWAYIDLE_CMD[@]})); then + printf '[pi-dev] Redémarrage de swayidle...\n' + nohup "${SWAYIDLE_CMD[@]}" \ + >/dev/null 2>&1 & + fi + ;; + esac + + printf '[pi-dev] Session restaurée.\n' + + exit "$rc" +} + +trap cleanup EXIT INT TERM HUP + + +# +# 1. Neutraliser swayidle +# + +if systemctl --user is-active --quiet swayidle.service 2>/dev/null; then + + SWAYIDLE_MODE="systemd" + + printf '[pi-dev] Neutralisation de swayidle.service...\n' + systemctl --user stop swayidle.service + +else + + SWAYIDLE_PID="$(pgrep -xo swayidle || true)" + + if [[ -n "$SWAYIDLE_PID" ]]; then + + SWAYIDLE_MODE="process" + + # Sauvegarder exactement argv avant d'arrêter swayidle. + mapfile -d '' SWAYIDLE_CMD \ + < "/proc/$SWAYIDLE_PID/cmdline" + + printf '[pi-dev] Neutralisation de swayidle (PID %s)...\n' \ + "$SWAYIDLE_PID" + + kill "$SWAYIDLE_PID" + + # Attendre sa disparition. + for _ in {1..20}; do + kill -0 "$SWAYIDLE_PID" 2>/dev/null || break + sleep 0.05 + done + fi +fi + + +# +# 2. Lancer Pi avec inhibition de la fermeture du capot +# +# L'inhibitor existe uniquement pendant que Pi tourne. +# + +printf '[pi-dev] swayidle neutralisé.\n' +printf '[pi-dev] Veille fermeture du capot neutralisée.\n' +printf '[pi-dev] Lancement de Pi / OpenRouter.\n\n' + +systemd-inhibit \ + --what=handle-lid-switch \ + --who="pi-dev" \ + --why="Session de développement Pi / Lardon3D" \ + --mode=block \ + pi "$@" diff --git a/bin/.local/bin/scan3d b/bin/.local/bin/scan3d new file mode 100755 index 000000000..e93e329a2 --- /dev/null +++ b/bin/.local/bin/scan3d @@ -0,0 +1,697 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# scan3d +# Pipeline interactif : +# photos -> pré-tri -> COLMAP -> OpenMVS -> maillage texturé +# +# Les photos originales ne sont jamais déplacées ni supprimées. +# Le script s'arrête avant l'ouverture de MeshLab. + +OPENMVS_BIN="${OPENMVS_BIN:-$HOME/Applications/openmvs-2.4.0/openmvs}" +OPENMVS_IMAGE="${OPENMVS_IMAGE:-localhost/openmvs:2.4.0}" + +THREADS="${SCAN3D_THREADS:-4}" +SEQUENTIAL_OVERLAP="${SCAN3D_OVERLAP:-20}" +RESOLUTION_LEVEL="${SCAN3D_RESOLUTION_LEVEL:-3}" +NUMBER_VIEWS="${SCAN3D_NUMBER_VIEWS:-4}" +MIN_FREE_GIB="${SCAN3D_MIN_FREE_GIB:-45}" + +USE_GPU=1 +KEEP_DEPTHMAPS=0 + +RED=$'\e[31m' +GREEN=$'\e[32m' +YELLOW=$'\e[33m' +BLUE=$'\e[34m' +BOLD=$'\e[1m' +RESET=$'\e[0m' + +log() { + printf '\n%s%s==>%s %s\n' "$BOLD" "$BLUE" "$RESET" "$*" +} + +ok() { + printf '%s✓%s %s\n' "$GREEN" "$RESET" "$*" +} + +warn() { + printf '%s⚠%s %s\n' "$YELLOW" "$RESET" "$*" >&2 +} + +die() { + printf '%sERREUR:%s %s\n' "$RED" "$RESET" "$*" >&2 + exit 1 +} + +cleanup_on_error() { + local code=$? + if (( code != 0 )); then + printf '\n%sLe pipeline s’est arrêté avec le code %d.%s\n' \ + "$RED" "$code" "$RESET" >&2 + printf 'Relance simplement %sscan3d%s pour reprendre à la dernière étape validée.\n' \ + "$BOLD" "$RESET" >&2 + fi +} +trap cleanup_on_error EXIT + +prompt_default() { + local prompt="$1" + local default="$2" + local answer + read -r -e -p "$prompt [$default] : " answer + printf '%s' "${answer:-$default}" +} + +yes_no() { + local prompt="$1" + local default="${2:-n}" + local answer suffix + + if [[ "$default" == "o" ]]; then + suffix="[O/n]" + else + suffix="[o/N]" + fi + + while true; do + read -r -p "$prompt $suffix : " answer + answer="${answer:-$default}" + case "${answer,,}" in + o|oui|y|yes) return 0 ;; + n|non|no) return 1 ;; + *) printf 'Réponds par o ou n.\n' ;; + esac + done +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || die "commande introuvable : $1" +} + +run_logged() { + local logfile="$1" + shift + mkdir -p "$(dirname "$logfile")" + "$@" 2>&1 | tee "$logfile" +} + +natural_list() { + find "$@" -print0 | sort -zV +} + +choose_source_dir() { + local default_dir="$PWD" + local entered + + while true; do + entered="$(prompt_default 'Dossier contenant les photos' "$default_dir")" + entered="${entered/#\~/$HOME}" + entered="$(realpath -m "$entered")" + + if [[ -d "$entered" ]]; then + RAW_DIR="$entered" + return + fi + + warn "Le dossier n’existe pas : $entered" + done +} + +choose_categories() { + printf '\n%sQuelles photos utiliser pour la reconstruction ?%s\n' "$BOLD" "$RESET" + printf ' 1) Bonnes uniquement — recommandé\n' + printf ' 2) Bonnes + suspectes — plus complet, plus risqué\n' + printf ' 3) Toutes, y compris mauvaises — déconseillé\n' + + local choice + while true; do + read -r -p 'Choix [1] : ' choice + choice="${choice:-1}" + case "$choice" in + 1) + SELECTED_CATEGORIES=("bonnes") + CATEGORY_LABEL="bonnes" + return + ;; + 2) + SELECTED_CATEGORIES=("bonnes" "suspectes") + CATEGORY_LABEL="bonnes-suspectes" + return + ;; + 3) + SELECTED_CATEGORIES=("bonnes" "suspectes" "mauvaises") + CATEGORY_LABEL="toutes" + return + ;; + *) + printf 'Choisis 1, 2 ou 3.\n' + ;; + esac + done +} + +write_tri_script() { + cat > "$TRI_PY" <<'PY' +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import csv +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +import cv2 +import numpy as np +from PIL import Image + +EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"} + + +@dataclass +class Result: + path: Path + category: str + sharpness: float + white: float + black: float + brightness: float + reason: str + + +def natural_key(path: Path) -> list[object]: + return [ + int(part) if part.isdigit() else part.lower() + for part in re.split(r"(\d+)", path.name) + ] + + +def load_gray(path: Path) -> np.ndarray: + with Image.open(path) as source: + source = source.convert("L") + gray = np.asarray(source) + + height, width = gray.shape + maximum_dimension = 1600 + + if max(width, height) > maximum_dimension: + scale = maximum_dimension / max(width, height) + gray = cv2.resize( + gray, + ( + max(1, round(width * scale)), + max(1, round(height * scale)), + ), + interpolation=cv2.INTER_AREA, + ) + + return gray + + +def analyze(path: Path) -> tuple[dict[str, float] | None, str]: + try: + gray = load_gray(path) + except Exception as error: + return None, f"illisible: {error}" + + return { + "sharpness": float(cv2.Laplacian(gray, cv2.CV_64F).var()), + "white": float(np.count_nonzero(gray >= 250) / gray.size * 100), + "black": float(np.count_nonzero(gray <= 5) / gray.size * 100), + "brightness": float(gray.mean()), + }, "" + + +def classify(path: Path, metrics: dict[str, float] | None, error: str) -> Result: + if metrics is None: + return Result(path, "mauvaises", 0.0, 0.0, 0.0, 0.0, error) + + sharpness = metrics["sharpness"] + white = metrics["white"] + black = metrics["black"] + brightness = metrics["brightness"] + + reasons: list[str] = [] + + if sharpness < 100: + reasons.append("très floue") + elif sharpness < 170: + reasons.append("floue possible") + + if white > 20: + reasons.append("très surexposée") + elif white > 8: + reasons.append("surexposition possible") + + if black > 35: + reasons.append("très sombre") + elif black > 15: + reasons.append("ombres bouchées possibles") + + if brightness > 225 or brightness < 25: + reasons.append("luminosité extrême") + + severe = ( + sharpness < 100 + or white > 20 + or black > 35 + or brightness > 225 + or brightness < 25 + ) + + if severe: + category = "mauvaises" + elif reasons: + category = "suspectes" + else: + category = "bonnes" + + return Result( + path, + category, + sharpness, + white, + black, + brightness, + "; ".join(reasons) or "ok", + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("images", type=Path) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + + images_dir = args.images.expanduser().resolve() + output_dir = args.output.expanduser().resolve() + + excluded_roots = { + output_dir, + images_dir / "scan3d", + images_dir / "tri_resultat", + images_dir / "test_colmap", + images_dir / "colmap_complet", + } + + images: list[Path] = [] + for path in images_dir.rglob("*"): + if not path.is_file() or path.is_symlink(): + continue + if path.suffix.lower() not in EXTENSIONS: + continue + if any(root == path or root in path.parents for root in excluded_roots): + continue + images.append(path) + + images.sort(key=natural_key) + + if not images: + print("Aucune image trouvée.", file=sys.stderr) + return 1 + + print(f"{len(images)} images trouvées.") + + results: list[Result] = [] + for index, path in enumerate(images, 1): + metrics, error = analyze(path) + results.append(classify(path, metrics, error)) + print( + f"\rAnalyse {index}/{len(images)} : {path.name[:55]:55s}", + end="", + flush=True, + ) + print() + + output_dir.mkdir(parents=True, exist_ok=True) + for category in ("bonnes", "suspectes", "mauvaises"): + category_dir = output_dir / category + if category_dir.exists(): + for child in category_dir.iterdir(): + if child.is_symlink() or child.is_file(): + child.unlink() + category_dir.mkdir(parents=True, exist_ok=True) + + with (output_dir / "resultats.csv").open( + "w", encoding="utf-8", newline="" + ) as csv_file: + writer = csv.writer(csv_file) + writer.writerow( + [ + "fichier", + "categorie", + "netteté", + "blanc_pct", + "noir_pct", + "luminosité", + "raison", + ] + ) + + counters = {"bonnes": 0, "suspectes": 0, "mauvaises": 0} + for result in results: + counters[result.category] += 1 + link_name = f"{counters[result.category]:06d}_{result.path.name}" + link_path = output_dir / result.category / link_name + link_path.symlink_to(result.path.resolve()) + + writer.writerow( + [ + str(result.path), + result.category, + f"{result.sharpness:.2f}", + f"{result.white:.2f}", + f"{result.black:.2f}", + f"{result.brightness:.2f}", + result.reason, + ] + ) + + for category in ("bonnes", "suspectes", "mauvaises"): + count = sum(result.category == category for result in results) + print(f"{category}: {count}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) +PY +} + +podman_openmvs() { + podman run --rm \ + -v "$OPENMVS_BIN:/openmvs:ro,Z" \ + -v "$WORK_DIR:/data:Z" \ + -w /data/openmvs \ + "$OPENMVS_IMAGE" \ + "$@" +} + +printf '%s\n' "${BOLD}scan3d — pipeline photogrammétrique COLMAP + OpenMVS${RESET}" + +require_command colmap +require_command podman +require_command /usr/bin/python +require_command realpath +require_command sort +require_command find +require_command sqlite3 + +/usr/bin/python - <<'PY' >/dev/null 2>&1 || die \ +"Modules Python manquants. Installe : sudo pacman -S python-opencv python-numpy python-pillow" +import cv2 +import numpy +from PIL import Image +PY + +[[ -x "$OPENMVS_BIN/InterfaceCOLMAP" ]] || + die "OpenMVS introuvable dans : $OPENMVS_BIN" +[[ -x "$OPENMVS_BIN/DensifyPointCloud" ]] || + die "DensifyPointCloud introuvable dans : $OPENMVS_BIN" + +podman image exists "$OPENMVS_IMAGE" || + die "Image Podman absente : $OPENMVS_IMAGE" + +choose_source_dir + +PROJECT_ROOT="$RAW_DIR/scan3d" +TRI_DIR="$PROJECT_ROOT/tri" +TRI_PY="$PROJECT_ROOT/tri_photos.py" + +mkdir -p "$PROJECT_ROOT" +write_tri_script + +if [[ -f "$TRI_DIR/resultats.csv" ]]; then + if yes_no "Un tri existe déjà. Le réutiliser ?" o; then + ok "Tri existant conservé." + else + log "Nouveau tri des photos" + rm -rf "$TRI_DIR" + /usr/bin/python "$TRI_PY" "$RAW_DIR" --output "$TRI_DIR" + fi +else + log "Tri des photos" + /usr/bin/python "$TRI_PY" "$RAW_DIR" --output "$TRI_DIR" +fi + +printf '\nRésultat du tri :\n' +for category in bonnes suspectes mauvaises; do + count="$(find "$TRI_DIR/$category" -maxdepth 1 -type l 2>/dev/null | wc -l)" + printf ' %-11s : %s\n' "$category" "$count" +done + +choose_categories + +WORK_DIR="$PROJECT_ROOT/projet-$CATEGORY_LABEL" + +if [[ -d "$WORK_DIR" ]] && find "$WORK_DIR" -mindepth 1 -print -quit | grep -q .; then + printf '\nUn projet existe déjà : %s\n' "$WORK_DIR" + printf ' 1) Reprendre là où il s’est arrêté\n' + printf ' 2) Tout effacer et recommencer\n' + printf ' 3) Annuler\n' + + while true; do + read -r -p 'Choix [1] : ' resume_choice + resume_choice="${resume_choice:-1}" + case "$resume_choice" in + 1) break ;; + 2) + rm -rf "$WORK_DIR" + break + ;; + 3) exit 0 ;; + *) printf 'Choisis 1, 2 ou 3.\n' ;; + esac + done +fi + +mkdir -p "$WORK_DIR"/{images,sparse,logs,openmvs} + +free_gib="$(df -Pk "$WORK_DIR" | awk 'NR==2 {printf "%d", $4/1024/1024}')" +printf '\nEspace libre : %s Gio\n' "$free_gib" +if (( free_gib < MIN_FREE_GIB )); then + warn "Moins de ${MIN_FREE_GIB} Gio libres." + yes_no "Continuer malgré le risque de remplir le disque ?" n || + exit 1 +fi + +if yes_no "Conserver les cartes de profondeur après le résultat final ?" n; then + KEEP_DEPTHMAPS=1 +fi + +if yes_no "Utiliser la Radeon via OpenGL pour l’extraction et le matching ?" o; then + USE_GPU=1 +else + USE_GPU=0 +fi + +log "Préparation des images sélectionnées" +rm -rf "$WORK_DIR/images" +mkdir -p "$WORK_DIR/images" + +index=1 +for category in "${SELECTED_CATEGORIES[@]}"; do + while IFS= read -r -d '' image; do + target="$(readlink -f "$image")" + [[ -f "$target" ]] || continue + + extension="${target##*.}" + extension="${extension,,}" + printf -v output_name 'img_%07d.%s' "$index" "$extension" + ln -s "$target" "$WORK_DIR/images/$output_name" + ((index++)) + done < <( + find "$TRI_DIR/$category" -maxdepth 1 -type l -print0 | sort -zV + ) +done + +IMAGE_COUNT="$(find "$WORK_DIR/images" -maxdepth 1 -type l | wc -l)" +(( IMAGE_COUNT > 0 )) || die "Aucune image sélectionnée." +ok "$IMAGE_COUNT images préparées." + +DATABASE="$WORK_DIR/database.db" + +if [[ ! -f "$WORK_DIR/.features.done" ]]; then + log "COLMAP — extraction des caractéristiques" + rm -f "$DATABASE" + run_logged "$WORK_DIR/logs/01-features.log" \ + colmap feature_extractor \ + --database_path "$DATABASE" \ + --image_path "$WORK_DIR/images" \ + --ImageReader.single_camera 1 \ + --ImageReader.camera_model SIMPLE_RADIAL \ + --FeatureExtraction.use_gpu "$USE_GPU" + touch "$WORK_DIR/.features.done" +else + ok "Extraction déjà terminée." +fi + +if [[ ! -f "$WORK_DIR/.matching.done" ]]; then + log "COLMAP — matching séquentiel" + run_logged "$WORK_DIR/logs/02-matching.log" \ + colmap sequential_matcher \ + --database_path "$DATABASE" \ + --FeatureMatching.use_gpu "$USE_GPU" \ + --SequentialMatching.overlap "$SEQUENTIAL_OVERLAP" + touch "$WORK_DIR/.matching.done" +else + ok "Matching déjà terminé." +fi + +if [[ ! -f "$WORK_DIR/.mapper.done" ]]; then + log "COLMAP — reconstruction clairsemée" + rm -rf "$WORK_DIR/sparse" + mkdir -p "$WORK_DIR/sparse" + run_logged "$WORK_DIR/logs/03-mapper.log" \ + colmap mapper \ + --database_path "$DATABASE" \ + --image_path "$WORK_DIR/images" \ + --output_path "$WORK_DIR/sparse" + touch "$WORK_DIR/.mapper.done" +else + ok "Reconstruction clairsemée déjà terminée." +fi + +BEST_MODEL="" +BEST_REGISTERED=-1 + +while IFS= read -r model; do + analysis="$(colmap model_analyzer --path "$model" 2>&1 || true)" + registered="$( + awk -F': ' '/Registered images:/ {print $NF; exit}' <<<"$analysis" | + tr -dc '0-9' + )" + [[ -n "$registered" ]] || registered=0 + printf '%s\n' "$analysis" > "$model/model-analysis.txt" + + printf ' Modèle %-5s : %s images enregistrées\n' \ + "$(basename "$model")" "$registered" + + if (( registered > BEST_REGISTERED )); then + BEST_REGISTERED="$registered" + BEST_MODEL="$model" + fi +done < <( + find "$WORK_DIR/sparse" -mindepth 1 -maxdepth 1 -type d | sort -V +) + +[[ -n "$BEST_MODEL" ]] || die "COLMAP n’a produit aucun modèle." +printf '%s\n' "$BEST_MODEL" > "$WORK_DIR/best-model.txt" + +ok "Meilleur modèle : $BEST_REGISTERED/$IMAGE_COUNT images." + +DENSE_DIR="$WORK_DIR/dense" + +if [[ ! -f "$WORK_DIR/.undistort.done" ]]; then + log "COLMAP — correction des images" + rm -rf "$DENSE_DIR" + run_logged "$WORK_DIR/logs/04-undistort.log" \ + colmap image_undistorter \ + --image_path "$WORK_DIR/images" \ + --input_path "$BEST_MODEL" \ + --output_path "$DENSE_DIR" \ + --output_type COLMAP + touch "$WORK_DIR/.undistort.done" +else + ok "Images déjà corrigées." +fi + +if [[ ! -f "$WORK_DIR/openmvs/scene.mvs" ]]; then + log "OpenMVS — import du modèle COLMAP" + run_logged "$WORK_DIR/logs/05-interface-colmap.log" \ + podman_openmvs \ + /openmvs/InterfaceCOLMAP \ + --input-file /data/dense \ + --image-folder /data/dense/images \ + --output-file /data/openmvs/scene.mvs \ + --working-folder /data/openmvs \ + --verbosity 1 +else + ok "Projet OpenMVS déjà importé." +fi + +if [[ ! -f "$WORK_DIR/openmvs/scene_dense.ply" ]]; then + log "OpenMVS — densification" + run_logged "$WORK_DIR/logs/06-densify.log" \ + podman_openmvs \ + /openmvs/DensifyPointCloud \ + --input-file /data/openmvs/scene.mvs \ + --output-file /data/openmvs/scene_dense.mvs \ + --working-folder /data/openmvs \ + --resolution-level "$RESOLUTION_LEVEL" \ + --number-views "$NUMBER_VIEWS" \ + --max-threads "$THREADS" \ + --verbosity 1 +else + ok "Nuage dense déjà généré." +fi + +if [[ ! -f "$WORK_DIR/openmvs/scene_dense_mesh.ply" ]]; then + log "OpenMVS — reconstruction du maillage" + run_logged "$WORK_DIR/logs/07-reconstruct-mesh.log" \ + podman_openmvs \ + /openmvs/ReconstructMesh \ + --input-file /data/openmvs/scene_dense.mvs \ + --output-file /data/openmvs/scene_dense_mesh.mvs \ + --working-folder /data/openmvs \ + --max-threads "$THREADS" \ + --min-point-distance 4 \ + --verbosity 1 +else + ok "Maillage déjà reconstruit." +fi + +if [[ ! -f "$WORK_DIR/openmvs/scene_dense_mesh_refine.ply" ]]; then + log "OpenMVS — raffinement du maillage" + run_logged "$WORK_DIR/logs/08-refine-mesh.log" \ + podman_openmvs \ + /openmvs/RefineMesh \ + --input-file /data/openmvs/scene_dense.mvs \ + --mesh-file /data/openmvs/scene_dense_mesh.ply \ + --output-file /data/openmvs/scene_dense_mesh_refine.mvs \ + --working-folder /data/openmvs \ + --resolution-level "$RESOLUTION_LEVEL" \ + --max-threads "$THREADS" \ + --verbosity 1 +else + ok "Maillage déjà raffiné." +fi + +if [[ ! -f "$WORK_DIR/openmvs/scene_dense_mesh_refine_texture.ply" ]]; then + log "OpenMVS — texturage" + run_logged "$WORK_DIR/logs/09-texture-mesh.log" \ + podman_openmvs \ + /openmvs/TextureMesh \ + --input-file /data/openmvs/scene_dense.mvs \ + --mesh-file /data/openmvs/scene_dense_mesh_refine.ply \ + --output-file /data/openmvs/scene_dense_mesh_refine_texture.mvs \ + --working-folder /data/openmvs \ + --max-threads "$THREADS" \ + --verbosity 1 +else + ok "Maillage déjà texturé." +fi + +if (( !KEEP_DEPTHMAPS )); then + log "Nettoyage des cartes de profondeur intermédiaires" + find "$WORK_DIR/openmvs" -maxdepth 1 -type f \ + \( -name 'depth*.png' -o -name 'depth*.conf.png' \) \ + -delete +fi + +FINAL="$WORK_DIR/openmvs/scene_dense_mesh_refine_texture.ply" +[[ -f "$FINAL" ]] || die "Le modèle final est absent." + +trap - EXIT + +printf '\n%s%sSCAN 3D TERMINÉ%s\n' "$BOLD" "$GREEN" "$RESET" +printf 'Modèle texturé :\n %s\n' "$FINAL" +printf 'Images enregistrées : %s/%s\n' "$BEST_REGISTERED" "$IMAGE_COUNT" +printf 'Espace occupé : ' +du -sh "$WORK_DIR" | awk '{print $1}' +printf '\nPour l’ouvrir manuellement :\n' +printf ' meshlab %q\n' "$FINAL" diff --git a/bin/.local/bin/scan3d-watch b/bin/.local/bin/scan3d-watch new file mode 100755 index 000000000..ccec219c4 --- /dev/null +++ b/bin/.local/bin/scan3d-watch @@ -0,0 +1,430 @@ +#!/usr/bin/env python3 +"""scan3d-watch — TUI Textual en lecture seule pour scan3d.""" + +from __future__ import annotations + +import os +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from time import monotonic + +import psutil +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.widgets import Footer, Label, ProgressBar, Static + + +PROCESS_NAMES = { + "colmap", + "DensifyPointCloud", + "ReconstructMesh", + "RefineMesh", + "TextureMesh", + "InterfaceCOLMAP", +} + +PROGRESS_PATTERNS = ( + re.compile(r"Processing image \[(\d+)/(\d+)\]"), + re.compile(r"Estimated depth-maps (\d+) \((\d+)%"), + re.compile(r"Geometric-consistent estimated depth-maps (\d+) \((\d+)%"), + re.compile(r"Dense fused depth-maps (\d+) \((\d+)%"), +) + +PHASES = ( + ("features", "Extraction"), + ("sequential", "Matching séquentiel"), + ("vocab", "Matching global"), + ("transitive", "Matching transitif"), + ("matching", "Matching"), + ("mapper", "Mapper"), + ("undistort", "Undistortion"), + ("interface", "Import OpenMVS"), + ("densify", "Densification"), + ("reconstruct", "Maillage"), + ("refine", "Raffinement"), + ("texture", "Texturage"), +) + + +@dataclass +class Progress: + phase: str = "En attente" + current: int = 0 + total: int = 0 + percent: float = 0.0 + message: str = "" + + +def human_bytes(value: int | float) -> str: + units = ("o", "Kio", "Mio", "Gio", "Tio") + number = float(value) + for unit in units: + if abs(number) < 1024 or unit == units[-1]: + return f"{number:.1f} {unit}" + number /= 1024 + return f"{number:.1f} Tio" + + +def directory_size(path: Path) -> int: + total = 0 + try: + for root, _dirs, files in os.walk(path): + for name in files: + try: + total += (Path(root) / name).stat().st_size + except OSError: + pass + except OSError: + pass + return total + + +def find_project(root: Path) -> Path: + scan3d = root / "scan3d" + return scan3d if scan3d.is_dir() else root + + +def latest_log(project: Path) -> Path | None: + logs: list[Path] = [] + try: + logs = [path for path in project.rglob("*.log") if path.is_file()] + except OSError: + return None + return max(logs, key=lambda path: path.stat().st_mtime, default=None) + + +def read_tail(path: Path, lines: int = 12) -> list[str]: + try: + with path.open("rb") as file: + file.seek(0, os.SEEK_END) + size = file.tell() + block = 8192 + data = b"" + while size > 0 and data.count(b"\n") <= lines: + read_size = min(block, size) + size -= read_size + file.seek(size) + data = file.read(read_size) + data + return data.decode("utf-8", errors="replace").splitlines()[-lines:] + except OSError: + return [] + +def detect_progress(project: Path) -> Progress: + log = latest_log(project) + if log is None: + return Progress() + + lower_name = log.name.lower() + phase = next( + (label for token, label in PHASES if token in lower_name), + log.stem, + ) + + result = Progress(phase=phase) + tail = read_tail(log, 120) + + # COLMAP : extraction ou matching. + for line in reversed(tail): + match = re.search(r"Processing image \[(\d+)/(\d+)\]", line) + if match: + current = int(match.group(1)) + total = int(match.group(2)) + + result.current = current + result.total = total + result.percent = current * 100 / total if total else 0 + result.message = line.strip() + return result + + # COLMAP Mapper : nombre d’images réellement enregistrées. + for line in reversed(tail): + match = re.search(r"num_reg_frames=(\d+)", line) + if not match: + continue + + current = int(match.group(1)) + + # Le log est normalement dans projet-*/logs/. + work_dir = log.parent.parent + images_dir = work_dir / "images" + + try: + total = sum( + 1 + for path in images_dir.iterdir() + if path.is_file() or path.is_symlink() + ) + except OSError: + total = 0 + + result.current = current + result.total = total + result.percent = current * 100 / total if total else 0 + result.message = line.strip() + return result + + # OpenMVS : cartes de profondeur. + for line in reversed(tail): + match = re.search( + r"(?:Estimated|Geometric-consistent estimated|Dense fused) " + r"depth-maps (\d+) \((\d+)%", + line, + ) + if match: + result.current = int(match.group(1)) + result.percent = float(match.group(2)) + result.message = line.strip() + return result + + if tail: + result.message = tail[-1].strip() + + return result + +def amd_gpu() -> tuple[str, str, str]: + for card in sorted(Path("/sys/class/drm").glob("card[0-9]*")): + device = card / "device" + try: + if (device / "vendor").read_text().strip() != "0x1002": + continue + except OSError: + continue + + busy = "?" + used = total = None + temperature = "?" + + try: + busy = (device / "gpu_busy_percent").read_text().strip() + except OSError: + pass + + try: + used = int((device / "mem_info_vram_used").read_text()) + total = int((device / "mem_info_vram_total").read_text()) + except (OSError, ValueError): + pass + + for hwmon in (device / "hwmon").glob("hwmon*"): + try: + temperature = f"{int((hwmon / 'temp1_input').read_text()) / 1000:.0f}°C" + break + except (OSError, ValueError): + continue + + vram = ( + f"{human_bytes(used)}/{human_bytes(total)}" + if used is not None and total is not None + else "indisponible" + ) + return f"{busy}%", vram, temperature + + return "absent", "—", "—" + +def active_process() -> str: + cpu_count = psutil.cpu_count(logical=True) or 1 + rows: list[tuple[float, str]] = [] + + for process in psutil.process_iter( + ["name", "cpu_percent", "memory_percent"] + ): + try: + name = process.info["name"] or "" + if name not in PROCESS_NAMES: + continue + + raw_cpu = float(process.info["cpu_percent"] or 0) + global_cpu = raw_cpu / cpu_count + memory = float(process.info["memory_percent"] or 0) + + rows.append( + ( + raw_cpu, + f"{name} CPU {global_cpu:.0f}% RAM {memory:.1f}%", + ) + ) + + except ( + psutil.NoSuchProcess, + psutil.AccessDenied, + TypeError, + ): + continue + + if not rows: + return "Aucun processus COLMAP/OpenMVS" + + rows.sort(reverse=True) + return rows[0][1] + +class Scan3DWatch(App[None]): + TITLE = "scan3d-watch" + SUB_TITLE = "lecture seule" + + BINDINGS = [ + Binding("q", "quit", "Quitter"), + Binding("r", "refresh_now", "Actualiser"), + Binding("l", "toggle_logs", "Logs"), + ] + + CSS = """ + Screen { + layout: vertical; + background: #11111b; + color: #cdd6f4; + } + + #title { + height: 1; + padding: 0 1; + background: #313244; + color: #cba6f7; + text-style: bold; + } + + #phase { + height: 3; + padding: 0 1; + border: round #89b4fa; + } + + ProgressBar { + height: 1; + margin: 0 1; + } + + #stats { + height: 7; + padding: 0 1; + border: round #a6e3a1; + } + + #project { + height: 5; + padding: 0 1; + border: round #f9e2af; + } + + #logs { + height: 1fr; + min-height: 4; + padding: 0 1; + border: round #fab387; + overflow-y: hidden; + } + + Footer { + height: 1; + } + + .hidden { + display: none; + } + """ + + def __init__(self, root: Path) -> None: + super().__init__() + self.root = root + self.project = find_project(root) + self.logs_visible = True + psutil.cpu_percent(interval=None) + + def compose(self) -> ComposeResult: + yield Label("", id="title") + yield Static("", id="phase") + yield ProgressBar(total=100, show_eta=False, id="progress") + yield Static("", id="stats") + yield Static("", id="project") + yield Static("", id="logs") + yield Footer() + + def on_mount(self) -> None: + self.refresh_data() + self.set_interval(1.0, self.refresh_data) + + def action_refresh_now(self) -> None: + self.refresh_data() + + def action_toggle_logs(self) -> None: + self.logs_visible = not self.logs_visible + logs = self.query_one("#logs") + logs.set_class(not self.logs_visible, "hidden") + + def refresh_data(self) -> None: + progress = detect_progress(self.project) + memory = psutil.virtual_memory() + swap = psutil.swap_memory() + disk = psutil.disk_usage(self.root) + cpu = psutil.cpu_percent(interval=None) + gpu, vram, gpu_temp = amd_gpu() + + self.query_one("#title", Label).update( + f"SCAN3D {self.root.name} • {self.project}" + ) + + progress_text = progress.phase + if progress.total: + progress_text += ( + f"\n{progress.current}/{progress.total} " + f"({progress.percent:.1f}%)" + ) + elif progress.percent: + progress_text += f"\n{progress.current} ({progress.percent:.1f}%)" + elif progress.message: + progress_text += "\nactivité détectée" + + self.query_one("#phase", Static).update(progress_text) + self.query_one("#progress", ProgressBar).update( + total=100, + progress=max(0, min(100, progress.percent)), + ) + + self.query_one("#stats", Static).update( + "[b]Système[/b]\n" + f"CPU {cpu:.0f}% GPU {gpu} Temp GPU {gpu_temp}\n" + f"RAM {human_bytes(memory.used)}/{human_bytes(memory.total)} " + f"dispo {human_bytes(memory.available)}\n" + f"Swap {human_bytes(swap.used)}/{human_bytes(swap.total)} " + f"VRAM {vram}\n" + f"{active_process()}" + ) + + size = directory_size(self.project) + self.query_one("#project", Static).update( + "[b]Projet[/b]\n" + f"Taille {human_bytes(size)}\n" + f"Disque {human_bytes(disk.used)}/{human_bytes(disk.total)} " + f"libre {human_bytes(disk.free)}" + ) + + log = latest_log(self.project) + if log is None: + log_text = "[b]Logs[/b]\nAucun fichier .log trouvé" + else: + lines = read_tail(log, 8) + cleaned = [ + re.sub(r"^[IWEF]\d+\s+\S+\s+\d+\s+", "", line) + for line in lines + ] + log_text = f"[b]Logs — {log.name}[/b]\n" + "\n".join(cleaned) + + self.query_one("#logs", Static).update(log_text) + + +def main() -> int: + root = Path(sys.argv[1] if len(sys.argv) > 1 else Path.cwd()) + root = root.expanduser().resolve() + + if not root.is_dir(): + print(f"Dossier introuvable : {root}", file=sys.stderr) + return 1 + + Scan3DWatch(root).run() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bin/.local/bin/sway-close b/bin/.local/bin/sway-close new file mode 100755 index 000000000..5991259ad --- /dev/null +++ b/bin/.local/bin/sway-close @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +app_id="$( + swaymsg -t get_tree | + jq -r '.. | objects | select(.focused? == true) | + (.app_id // .window_properties.class // "")' +)" + +case "$app_id" in + limusic-app|LiMusic|limusic) + pkill -TERM -x limusic-app + ;; + *) + swaymsg kill + ;; +esac diff --git a/ytmusic-tui/.config/ytmusic-tui/config.toml b/ytmusic-tui/.config/ytmusic-tui/config.toml new file mode 100644 index 000000000..d6c5bd60b --- /dev/null +++ b/ytmusic-tui/.config/ytmusic-tui/config.toml @@ -0,0 +1,6 @@ +[ui] +theme = "catppuccin" + +[player] +volume = 80 +audio_quality = "high"