feat(bin): track canonical user launchers

This commit is contained in:
fy59 2026-09-25 05:42:29 +02:00
parent 7da6cec15c
commit 8551db9e35
10 changed files with 1391 additions and 2 deletions

10
bin/.local/bin/colmap-amd Executable file
View file

@ -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 "$@"

33
bin/.local/bin/firefox-scribd Executable file
View file

@ -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"

View file

@ -1 +0,0 @@
/home/fy59/.local/src/llama.cpp/build/bin/llama-cli

View file

@ -1 +0,0 @@
/home/fy59/.local/src/llama.cpp/build/bin/llama-server

108
bin/.local/bin/opencode-away Executable file
View file

@ -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"

91
bin/.local/bin/pi-dev Executable file
View file

@ -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 "$@"

697
bin/.local/bin/scan3d Executable file
View file

@ -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"

430
bin/.local/bin/scan3d-watch Executable file
View file

@ -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())

16
bin/.local/bin/sway-close Executable file
View file

@ -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

View file

@ -0,0 +1,6 @@
[ui]
theme = "catppuccin"
[player]
volume = 80
audio_quality = "high"