Compare commits

..

8 Commits

Author SHA1 Message Date
Nicolas Bertrand 13c9bdfd25 Leds.html 2025-09-25 18:26:29 +02:00
Nicolas Bertrand e41f0b92d8 Add Leds 2025-09-25 18:26:01 +02:00
Nicolas Bertrand d75427eac1 Update preview 2025-09-18 10:33:55 +02:00
Nicolas Bertrand 9d40f213be HLS try 2025-09-18 09:44:24 +02:00
Nicolas Bertrand 7d9360576f Refactor camera configuration UI and enhance live preview functionality 2025-09-04 18:25:43 +02:00
Nicolas Bertrand 7ea52bc571 Display all params 2025-09-04 11:07:27 +02:00
Nicolas Bertrand 7eb06bbd57 WIP 2025-09-03 22:19:03 +02:00
Nicolas Bertrand 5c3bb99d52 Add Hardware 2024-11-25 15:46:49 +01:00
12 changed files with 652 additions and 2 deletions

6
.gitignore vendored
View File

@ -184,3 +184,9 @@ poetry.toml
pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python
# Custom ignores
src/nenuscanner/static/feed.jpg
db.sqlite

View File

@ -1,12 +1,48 @@
# NenuScanner
## Lancer l application flask depuis la racine
### Intall local de l applcation
Avoir un venv
puis
```
pip install -e .
```
dans src/nenuscanner
cp config.local.py dans config.py
### Lancer l'application
```
flask --app . run --debug
```
resoudre dependance si besoins genre:
```
pip install gpiod==2.1.3
```
## GPIO
### Cablage des LEDs existantes de la salle noire
| GPIO | ID LED salle noire |
| GPIO 17 | LED 2 |
| GPIO 17 | LED 2 |
| GPIO 18 | LED 3 |
| GPIO 27 | LED 5 |
| GPIO 23 | LED 6 |
@ -83,6 +119,12 @@ gphoto2 --set-config iso=1
## Hardware
### LED
LED COB, Cree LED, série CXA2, 90CRI, 2700K Blanc, CXB1512-0000-000N0UK427G 22W
https://fr.rs-online.com/web/p/led-cob/8847355
### Connecteur
VsAT ID

51
capture-image.py Normal file
View File

@ -0,0 +1,51 @@
#!/usr/bin/env python
# python-gphoto2 - Python interface to libgphoto2
# http://github.com/jim-easterbrook/python-gphoto2
# Copyright (C) 2015-22 Jim Easterbrook jim@jim-easterbrook.me.uk
#
# This file is part of python-gphoto2.
#
# python-gphoto2 is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# python-gphoto2 is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with python-gphoto2. If not, see
# <https://www.gnu.org/licenses/>.
import logging
import locale
import os
import subprocess
import sys
import gphoto2 as gp
def main():
locale.setlocale(locale.LC_ALL, '')
logging.basicConfig(
format='%(levelname)s: %(name)s: %(message)s', level=logging.WARNING)
callback_obj = gp.check_result(gp.use_python_logging())
camera = gp.Camera()
camera.init()
print('Capturing image')
file_path = camera.capture(gp.GP_CAPTURE_IMAGE)
print('Camera file path: {0}/{1}'.format(file_path.folder, file_path.name))
target = os.path.join('/tmp', file_path.name)
print('Copying image to', target)
camera_file = camera.file_get(
file_path.folder, file_path.name, gp.GP_FILE_TYPE_NORMAL)
camera_file.save(target)
subprocess.call(['xdg-open', target])
camera.exit()
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,13 +1,64 @@
import subprocess
from flask import jsonify
import gphoto2 as gp
import shutil
from . import leds, config
import subprocess
import json
def parse_config(lines):
config = {}
block = []
for line in lines:
line = line.strip()
if not line:
continue
if line.startswith('/main/'):
# Nouveau bloc : traiter le précédent s'il existe
if block:
insert_block(config, block)
block = []
block.append(line)
elif line == 'END':
block.append(line)
insert_block(config, block)
block = []
else:
block.append(line)
return config
def insert_block(config, block):
path = block[0].strip('/').split('/') # ['main', 'actions', 'syncdatetimeutc']
data = {}
for line in block[1:-1]: # Exclure le chemin et 'END'
if ':' in line:
key, value = line.split(':', 1)
key = key.strip()
value = value.strip()
if key.startswith('Choice'):
# Extrait les choix dans une liste
idx, label = value.split(' ', 1)
data.setdefault('Choices', []).append({'id': int(idx), 'label': label})
else:
data[key] = value
# Insère dans le dictionnaire hiérarchique
d = config
for part in path[:-1]: # Traverse les sections, ex: main -> actions
d = d.setdefault(part, {})
d[path[-1]] = data # Attribue les données à la clé finale
class Camera:
def capture(self):
return None
def config(self):
return None
class RealCamera(Camera):
def __init__(self):
@ -26,6 +77,16 @@ class RealCamera(Camera):
except Exception as e:
print('An error occured when capturing photo', e)
return None
def capture_preview(self):
try:
subprocess.run(
"gphoto2 --capture-preview --stdout > src/nenuscanner/static/feed.jpg",
shell=True, check=True
)
except subprocess.CalledProcessError as e:
print('An error occured when capturing photo', e)
raise CameraException(f"Erreur lors de la capture de l'image: {e}")
def save(self, capture, output_file):
preview = self.inner.file_get(capture.folder, capture.name[:-3] + 'JPG', gp.GP_FILE_TYPE_NORMAL)
@ -35,6 +96,23 @@ class RealCamera(Camera):
subprocess.run(['convert', output_file + '.jpg', '-resize', '10%', output_file + '.jpg'])
raw.save(output_file + '.cr2')
def config(self):
res = subprocess.run(["gphoto2", "--list-all-config"], capture_output=True, encoding="utf-8")
# print(res.stdout[:200])
configs = res.stdout.split("\n")
print(configs)
config_dict = parse_config(configs)
# Sauvegarde en JSON
with open("configCamera.json", "w", encoding="utf-8") as f:
json.dump(config_dict, f, indent=2, ensure_ascii=False)
def set_config(self, parameter, value):
subprocess.run(["gphoto2", "--set-config", f"{parameter}={value}"])
return 0
class DummyCamera(Camera):
def __init__(self, leds: leds.DummyLeds):
@ -73,3 +151,15 @@ camera = DummyCamera(leds.get()) if config.CAMERA == "dummy" else RealCamera()
def get():
return camera
def config():
return camera.config()
def set_config(parameter, value):
return camera.set_config(parameter, value)
class CameraException(Exception):
"""Exception personnalisée pour les erreurs liées à la caméra."""
def __init__(self, message):
super().__init__(message)

View File

@ -1,7 +1,7 @@
from flask import Blueprint, render_template
from .. import db
from . import object, calibration, acquisition
from . import object, calibration, acquisition, camera, leds
blueprint = Blueprint('routes', __name__)
@ -20,3 +20,5 @@ def index():
blueprint.register_blueprint(object.blueprint, url_prefix='/object')
blueprint.register_blueprint(calibration.blueprint, url_prefix='/calibration')
blueprint.register_blueprint(acquisition.blueprint, url_prefix='/acquisition')
blueprint.register_blueprint(camera.blueprint, url_prefix='/camera')
blueprint.register_blueprint(leds.blueprint, url_prefix='/leds')

View File

@ -0,0 +1,109 @@
from flask import Blueprint, render_template, request, send_file, jsonify
import json
import subprocess
from .. import camera as C
blueprint = Blueprint('camera', __name__)
# Routes for object management
@blueprint.route('/')
def get():
"""
Returns the page showing camera configuration for all parameters in capturesettings and imgsettings,
grouped by section.
"""
return render_template(
'camera.html')
@blueprint.route('/set', methods=['POST'])
def set_camera_settings():
"""
Receives and processes new camera settings for all parameters from the client.
"""
data = request.get_json()
updated = {}
for key, value in data.items():
print(f"Received {key}: {value}")
C.set_config(key, value)
updated[key] = value
try:
cam = C.get()
cam.capture_preview()
return jsonify({'status': 'ok'})
except C.CameraException as e:
return jsonify({'status': 'error', 'error': str(e)}), 500
return {'status': 'ok', **updated}
@blueprint.route('/feed.jpg')
def camera_feed():
return send_file('static/feed.jpg', mimetype='image/jpeg')
@blueprint.route('/config', methods=['GET'])
def get_camera_config():
"""
Returns grouped camera parameters as JSON for frontend JS.
"""
try:
cam = C.get()
cam.config()
except C.CameraException as e:
return jsonify({'status': 'error', 'error': str(e)}), 500
with open('configCamera.json', 'r') as f:
config = json.load(f)
grouped_params = []
# Iterate over all sections in config['main']
for section, settings in config['main'].items():
section_params = []
if isinstance(settings, dict):
for param_name, param in settings.items():
if 'Choices' in param and isinstance(param['Choices'], list) and param['Choices']:
choices = [
{'value': c.get('id', idx), 'label': c['label']}
for idx, c in enumerate(param['Choices'])
]
section_params.append({
'name': param_name,
'label': param.get('Label', param_name.capitalize()),
'choices': choices,
'current': param.get('Current', ''),
'Type': param.get('Type', 'Text'),
'Readonly': param.get('Readonly', 0)
})
if section_params:
grouped_params.append({
'section': section,
'params': section_params
})
return jsonify(grouped_params)
@blueprint.route('/capture_preview', methods=['POST'])
def capture_preview():
"""
Capture un aperçu avec gphoto2 et sauvegarde dans static/feed.jpg
"""
try:
cam = C.get()
cam.capture_preview()
return jsonify({'status': 'ok'})
except C.CameraException as e:
return jsonify({'status': 'error', 'error': str(e)}), 500

View File

@ -0,0 +1,45 @@
from flask import Blueprint, render_template, request, send_file, jsonify
import json
import subprocess
from .. import camera as C
from .. import leds
blueprint = Blueprint('leds', __name__)
# Routes for object management
@blueprint.route('/')
def get():
"""
Returns the pages showing all leds.
"""
return render_template(
'leds.html')
@blueprint.route('/set', methods=['POST'])
def set_led():
"""
Reçoit une commande pour allumer ou éteindre une LED.
Attend un JSON : { "led": "led1", "state": "on" } ou { "led": "led2", "state": "off" }
"""
data = request.get_json()
led = data.get('led')
state = data.get('state')
ledId=int(led[3])
with leds.get() as gpio_leds:
print(gpio_leds.leds)
gpio_led=gpio_leds.leds[ledId]
if state == "on":
gpio_led.on()
else:
gpio_led.off()
print(f"Commande reçue pour {led} : {state}")
return jsonify({'status': 'ok', 'led': led, 'state': state})

View File

@ -0,0 +1,59 @@
.cameraSettings {
height: 800px;
border: 1px solid;
overflow: scroll;
scrollbar-color: red orange;
scrollbar-width: thin;
}
img#preview {
width: 640px;
height: 480px;
border: 1px solid black;
}
.switch-slice {
display: flex;
align-items: center;
gap: 0.5em;
}
.switch-slice label {
font-weight: bold;
}
.switch-slice .switch {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
}
.switch-slice .switch input {
opacity: 0;
width: 0;
height: 0;
}
.switch-slice .slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 24px;
}
.switch-slice .slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
.switch-slice input:checked + .slider {
background-color: #48c774;
}
.switch-slice input:checked + .slider:before {
transform: translateX(26px);
}

View File

@ -4,7 +4,9 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NenuScanner</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<link rel="stylesheet" href="/static/custom.css">
{% block extracss %}{% endblock extracss %}
</head>
<body>

View File

@ -0,0 +1,163 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title">Configurer la camera</h1>
<form id="camera-config-form">
<div class="columns">
<div class="column is-half">
<div class="cameraSettings" id="camera-config-container"></div>
</div>
<div class="column is-half has-text-centered">
<figure class="image is-4by3" style="max-width: 480px; margin: auto;">
<img class="preview" id="camera-preview" src="/static/feed.jpg?{{ range(1000000)|random }}" alt="Camera Preview"
style="border: 1px solid #ccc;">
</figure>
<button class="button is-small is-info mt-2" type="button" onclick="refreshPreview()">Rafraîchir
laperçu
</button>
<button class="button is-small is-info mt-2" type="button" onclick="CapturePreview()">Acquistion camera
</button>
<nav class="level">
<div class="level-item has-text-centered">
<div>
<p class="heading">Speed</p>
<p id="display-camera-shutterspeed" class="title is-5">Updating ...</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">f/</p>
<p id="display-camera-aperture" class="title is-5"> Updating ...</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">ISO</p>
<p id="display-camera-iso" class="title is-5">Updating ...</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">°K</p>
<p id="display-camera-color-temperature" class="title is-5">Updating ...</p>
</div>
</div>
</nav>
</div>
</div>
</form>
</div>
</section>
{% endblock content %}
{% block extrajs %}
<script>
function setConfig(paramName) {
const value = document.getElementById(paramName).value;
fetch('/camera/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [paramName]: value })
}).then(() => location.reload());
}
window.refreshPreview = function () {
const img = document.getElementById('camera-preview');
const url = '/static/feed.jpg?' + new Date().getTime();
img.src = url;
}
setInterval(window.refreshPreview, 1000); // Refresh every second
document.addEventListener('DOMContentLoaded', function () {
fetch('/camera/config')
.then(response => response.json())
.then(data => {
console.log(data);
// data is grouped_params
const container = document.getElementById('camera-config-container');
container.innerHTML = '';
const captureSection = data.find(group => group.section === 'capturesettings');
if (captureSection) {
// Find the shutterspeed param
const shutterParam = captureSection.params.find(param => param.name === 'shutterspeed');
if (shutterParam) {
document.getElementById('display-camera-shutterspeed').textContent = shutterParam.current;
}
// find the aperture param
const apertureParam = captureSection.params.find(param => param.name === 'aperture');
if (apertureParam) {
document.getElementById('display-camera-aperture').textContent = apertureParam.current;
}
}
const imgSection = data.find(group => group.section === 'imgsettings');
if (imgSection) {
// Find the iso param
const isoParam = imgSection.params.find(param => param.name === 'iso');
if (isoParam) {
document.getElementById('display-camera-iso').textContent = isoParam.current;
}
// find the whitebalance param
const wbParam = imgSection.params.find(param => param.name === 'colortemperature');
if (wbParam) {
document.getElementById('display-camera-color-temperature').textContent = wbParam.current;
}
}
data.forEach(group => {
const box = document.createElement('div');
box.className = 'box';
box.innerHTML = `<h2 class="subtitle has-text-weight-bold">${group.section.replace('settings', ' Settings')}</h2>`;
group.params.forEach(param => {
let field = document.createElement('div');
if (param.type == 'TEXT') {
field.className = 'text';
field.innerHTML = `
<p> ${param.name} </p>
`;
}else {
field.className = 'field';
field.innerHTML = `
<label class="label" for="${param.name}">${param.label}:</label>
<div class="control">
<div class="select is-fullwidth">
<select id="${param.name}" name="${param.name}">
${param.choices.map(choice =>
`<option value="${choice.value}" ${choice.label === param.current ? 'selected' : ''}>
${choice.label} (${choice.value})
</option>`
).join('')}
</select>
</div>
</div>
<div class="control mt-2">
<button class="button is-small is-primary" type="button" onclick="setConfig('${param.name}')">Set</button>
</div>
`;
}
box.appendChild(field);
});
container.appendChild(box);
});
});
});
function CapturePreview() {
fetch('/camera/capture_preview', {method: 'POST'})
.then(response => response.json())
.then(data => {
if (data.status === 'ok') {
refreshPreview(); // Rafraîchir l'image après la capture
} else {
alert('Erreur lors de la capture : ' + (data.error || 'inconnue'));
}
});
}
</script>
{% endblock extrajs %}

View File

@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title">Conroler les LEDS</h1>
<div class="columns">
<div class="column is-half">
<div class="switch-slice">
<h2>LED 1</h2>
<label for="led-switch">OFF</label>
<label class="switch">
<input type="checkbox" id="led-switch-led1">
<span class="slider"></span>
</label>
<label for="led-switch">ON</label>
</div>
<div class="switch-slice">
<h2>LED 2</h2>
<label for="led-switch">OFF</label>
<label class="switch">
<input type="checkbox" id="led-switch-led2">
<span class="slider"></span>
</label>
<label for="led-switch">ON</label>
</div>
<div class="switch-slice">
<h2>LED 3</h2>
<label for="led-switch">OFF</label>
<label class="switch">
<input type="checkbox" id="led-switch-led3">
<span class="slider"></span>
</label>
<label for="led-switch">ON</label>
</div>
<div class="column">
</div>
</div>
</section>
{% endblock content %}
{% block extrajs %}
<script>
function setLedState(ledName, state) {
fetch('/leds/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ led: ledName, state: state })
})
.then(response => response.json())
.then(data => {
if (data.status !== 'ok') {
alert('Erreur lors du contrôle de la LED : ' + (data.error || 'inconnue'));
}
});
}
// Exemple dattachement dévénement pour plusieurs LEDs
document.addEventListener('DOMContentLoaded', function () {
['led1', 'led2', 'led3'].forEach(function (ledName) {
const checkbox = document.getElementById('led-switch-' + ledName);
if (checkbox) {
checkbox.addEventListener('change', function () {
setLedState(ledName, checkbox.checked ? 'on' : 'off');
});
}
});
});
</script>
{% endblock extrajs %}

7
stream.sh Executable file
View File

@ -0,0 +1,7 @@
#!/bin/bash
# Strame ok mais stream imge fixe
#gphoto2 --stdout --capture-movie | ffmpeg -i - -c:v libx264 -f hls -hls_time 2 -hls_list_size 3 -hls_flags delete_segments src/nenuscanner/static/stream.m3u8
gphoto2 --capture-image-and-download -F 0 -I 2 --stdout | ffmpeg -f image2pipe -framerate 0.5 -i - -r 25 -c:v libx264 -f hls -hls_time 2 -hls_list_size 3 -hls_flags delete_segments -g 50 -keyint_min 50 src/nenuscanner/static/stream.m3u8