Compare commits

...

4 Commits

Author SHA1 Message Date
Nicolas Bertrand 7c5ab98553 Working 2025-10-10 00:42:36 +02:00
Nicolas Bertrand 5fba1ce900 First version running with One led 2025-10-09 20:01:45 +02:00
Nicolas Bertrand 35b039a39a update calibration 2025-10-09 19:44:31 +02:00
Nicolas Bertrand 80d259e519 1st version with led driver 2025-10-09 19:44:26 +02:00
9 changed files with 101 additions and 84 deletions

View File

@ -2,6 +2,8 @@ from flask import Flask, send_from_directory, session
import os import os
from . import db, config, routes, utils, leds from . import db, config, routes, utils, leds
leds.get().enter()
app = Flask(__name__) app = Flask(__name__)
# Manage secret key # Manage secret key

View File

@ -28,7 +28,7 @@ def calibrate(input_dir: str):
# Camera parameters # Camera parameters
nu, nv, nc = images[0].shape nu, nv, nc = images[0].shape
nspheres = 5 nspheres = 4
focal_mm = 35 focal_mm = 35
matrix_size = 24 matrix_size = 24
focal_pix = nu * focal_mm / matrix_size focal_pix = nu * focal_mm / matrix_size
@ -41,14 +41,15 @@ def calibrate(input_dir: str):
pixels = np.reshape(max_image / 255.0, (-1, 3)) pixels = np.reshape(max_image / 255.0, (-1, 3))
# Initialize parameters for GMM # Initialize parameters for GMM
init_params = np.ones(2), np.broadcast_to(np.eye(3) * 0.1, (2, 3, 3)), np.asarray([[0, 0, 0], [1, 1, 1]]) # init_params = np.ones(2), np.broadcast_to(np.eye(3) * 0.1, (2, 3, 3)), np.asarray([[0, 0, 0], [1, 1, 1]])
# Estimate GMM parameters and classify pixels # Estimate GMM parameters and classify pixels
estimated_params = math_utils.gaussian_mixture_estimation(pixels, init_params, it=10) # estimated_params = math_utils.gaussian_mixture_estimation(pixels, init_params, it=10)
classif = np.asarray(math_utils.maximum_likelihood(pixels, estimated_params), dtype=bool) # classif = np.asarray(math_utils.maximum_likelihood(pixels, estimated_params), dtype=bool)
# Refine classification to select the appropriate binary mask # Refine classification to select the appropriate binary mask
rectified_classif = math_utils.select_binary_mask(classif, lambda mask: np.mean(pixels[mask])) # rectified_classif = math_utils.select_binary_mask(classif, lambda mask: np.mean(pixels[mask]))
rectified_classif = np.mean(pixels, axis=-1) > 0.03
# Identify the largest connected components (spheres) and extract their borders # Identify the largest connected components (spheres) and extract their borders
sphere_masks = math_utils.get_greatest_components(np.reshape(rectified_classif, (nu, nv)), nspheres) sphere_masks = math_utils.get_greatest_components(np.reshape(rectified_classif, (nu, nv)), nspheres)
@ -79,7 +80,7 @@ def calibrate(input_dir: str):
# Load grey values from images for the identified sphere regions # Load grey values from images for the identified sphere regions
def to_grayscale(image): def to_grayscale(image):
return [np.mean(image, axis=-1)[sphere_geometric_masks[i]] / 255.0 for i in range(nspheres)] return [np.power(np.mean(image, axis=-1)[sphere_geometric_masks[i]] / 255.0, 1.0) for i in range(nspheres)]
grey_values = np.asarray(list(map(to_grayscale, images)), dtype=object) grey_values = np.asarray(list(map(to_grayscale, images)), dtype=object)

View File

@ -6,6 +6,8 @@ import shutil
from . import leds, config from . import leds, config
import subprocess import subprocess
import json import json
from PIL import Image
import io
def parse_config(lines): def parse_config(lines):
@ -62,14 +64,18 @@ class Camera:
class RealCamera(Camera): class RealCamera(Camera):
def __init__(self): def __init__(self):
self._entered = False
self.inner = gp.Camera() self.inner = gp.Camera()
def __enter__(self): def __enter__(self):
self.inner.init() if not self._entered:
self._entered = True
self.inner.init()
return self return self
def __exit__(self, *args): def __exit__(self, *args):
self.inner.exit() # self.inner.exit()
pass
def capture(self): def capture(self):
try: try:
@ -79,14 +85,11 @@ class RealCamera(Camera):
return None return None
def capture_preview(self): def capture_preview(self):
try: capture = gp.check_result(gp.gp_camera_capture_preview(self.inner))
subprocess.run( file_data = gp.check_result(gp.gp_file_get_data_and_size(capture))
"gphoto2 --capture-preview --stdout > src/nenuscanner/static/feed.jpg", data = memoryview(file_data)
shell=True, check=True image = Image.open(io.BytesIO(file_data))
) image.save("src/nenuscanner/static/feed.jpg")
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): def save(self, capture, output_file):
preview = self.inner.file_get(capture.folder, capture.name[:-3] + 'JPG', gp.GP_FILE_TYPE_NORMAL) preview = self.inner.file_get(capture.folder, capture.name[:-3] + 'JPG', gp.GP_FILE_TYPE_NORMAL)
@ -97,8 +100,17 @@ class RealCamera(Camera):
raw.save(output_file + '.cr2') raw.save(output_file + '.cr2')
def config(self): def config(self):
was_entered = self._entered
if self._entered:
self._entered = False
self.inner.exit()
res = subprocess.run(["gphoto2", "--list-all-config"], capture_output=True, encoding="utf-8") res = subprocess.run(["gphoto2", "--list-all-config"], capture_output=True, encoding="utf-8")
if was_entered:
self.__enter__()
# print(res.stdout[:200]) # print(res.stdout[:200])
configs = res.stdout.split("\n") configs = res.stdout.split("\n")
@ -162,4 +174,4 @@ def set_config(parameter, value):
class CameraException(Exception): class CameraException(Exception):
"""Exception personnalisée pour les erreurs liées à la caméra.""" """Exception personnalisée pour les erreurs liées à la caméra."""
def __init__(self, message): def __init__(self, message):
super().__init__(message) super().__init__(message)

View File

@ -35,10 +35,10 @@ class GpioLed:
self.led = None self.led = None
def on(self): def on(self):
self.led.set_value(1) self.led.set_value(0)
def off(self): def off(self):
self.led.set_value(0) self.led.set_value(1)
def __str__(self): def __str__(self):
return f'LED{self.gpio_pin:02}' return f'LED{self.gpio_pin:02}'
@ -46,19 +46,25 @@ class GpioLed:
class GpioLeds(Leds): class GpioLeds(Leds):
def __init__(self, chip: str, gpio_pins: list[int]): def __init__(self, chip: str, gpio_pins: list[int]):
self._entered = False
self.chip = gpiod.Chip(chip) self.chip = gpiod.Chip(chip)
self.leds = [] self.leds = []
for pin in gpio_pins: for pin in gpio_pins:
self.leds.append(GpioLed(pin)) self.leds.append(GpioLed(pin))
def __enter__(self): def __enter__(self):
for led in self.leds: if not self._entered:
led.enter(self.chip) self._entered = True
for led in self.leds:
led.enter(self.chip)
return self return self
def __exit__(self, *args): def __exit__(self, *args):
for led in self.leds: # for led in self.leds:
led.exit() # led.exit()
# self._entered = False
pass
def off(self): def off(self):
for led in self.leds: for led in self.leds:
@ -141,4 +147,4 @@ _leds = GpioLeds(config.GPIO_CHIP, config.LEDS_UUIDS) if config.GPIO_CHIP is not
def get() -> Leds: def get() -> Leds:
return _leds return _leds

View File

@ -1,3 +1,4 @@
import subprocess
from flask import Blueprint, render_template from flask import Blueprint, render_template
from .. import db from .. import db
@ -16,6 +17,21 @@ def index():
projects = db.Object.all_by_project(conn) projects = db.Object.all_by_project(conn)
return render_template('index.html', projects=projects) return render_template('index.html', projects=projects)
# Route that restarts the server
@blueprint.route("/restart")
def restart():
"""
Serves the index of nenuscanner.
"""
subprocess.Popen(
["bash", "-c", "sleep 1 && systemctl restart nenuscanner --user"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True
)
return render_template('restart.html')
blueprint.register_blueprint(object.blueprint, url_prefix='/object') blueprint.register_blueprint(object.blueprint, url_prefix='/object')
blueprint.register_blueprint(calibration.blueprint, url_prefix='/calibration') blueprint.register_blueprint(calibration.blueprint, url_prefix='/calibration')

View File

@ -44,13 +44,11 @@ def set_camera_settings():
return {'status': 'ok', **updated} return {'status': 'ok', **updated}
@blueprint.route('/feed.jpg') @blueprint.route('/feed.jpg', methods=['GET'])
def camera_feed(): def camera_feed():
capture_preview()
return send_file('static/feed.jpg', mimetype='image/jpeg') return send_file('static/feed.jpg', mimetype='image/jpeg')
@blueprint.route('/config', methods=['GET']) @blueprint.route('/config', methods=['GET'])
def get_camera_config(): def get_camera_config():
""" """
@ -96,14 +94,14 @@ def get_camera_config():
return jsonify(grouped_params) return jsonify(grouped_params)
@blueprint.route('/capture_preview', methods=['POST']) # @blueprint.route('/capture_preview', methods=['POST'])
def capture_preview(): def capture_preview():
""" """
Capture un aperçu avec gphoto2 et sauvegarde dans static/feed.jpg Capture un aperçu avec gphoto2 et sauvegarde dans static/feed.jpg
""" """
try: try:
cam = C.get() with C.get() as cam:
cam.capture_preview() cam.capture_preview()
return jsonify({'status': 'ok'}) return jsonify({'status': 'ok'})
except C.CameraException as e: except C.CameraException as e:
return jsonify({'status': 'error', 'error': str(e)}), 500 return jsonify({'status': 'error', 'error': str(e)}), 500

View File

@ -2,58 +2,21 @@ from flask import Blueprint, render_template, request, send_file, jsonify, sessi
import json import json
import subprocess import subprocess
import gpiod
from .. import camera as C from .. import camera as C
from .. import leds,config from .. import leds,config
blueprint = Blueprint('leds', __name__) blueprint = Blueprint('leds', __name__)
# WARNING: This is a temporary global variable to hold the state of the GPIO LEDs.
# This is necessary because the LED state must persist across multiple requests,
# and Flask does not maintain state between requests.
# A better solution would be to implement a proper state management system.
def _get_gpio_leds():
"""Return a singleton leds controller stored in app.extensions."""
app = current_app._get_current_object()
ext_key = 'nenuscanner_gpio_leds'
if ext_key not in app.extensions:
# create and store the resource
app.extensions[ext_key] = leds.get().enter()
return app.extensions[ext_key]
@blueprint.record_once
def _register_cleanup(state):
"""
Register a teardown handler on the app that will try to exit the leds controller
when the app context is torn down.
"""
app = state.app
ext_key = 'nenuscanner_gpio_leds'
@app.teardown_appcontext
def _cleanup(exception=None):
gpio = app.extensions.get(ext_key)
if gpio:
try:
gpio.exit()
except Exception:
# best effort cleanup, don't raise during teardown
pass
# Routes for object management # Routes for object management
@blueprint.route('/') @blueprint.route('/')
def get(): def get():
""" """
Returns the pages showing all leds. Returns the pages showing all leds.
""" """
gpio_leds = _get_gpio_leds()
print(gpio_leds)
for i, led in enumerate(gpio_leds.leds):
print(f"LED {i}: {led}, is_on={led.is_on}")
return render_template( return render_template(
'leds.html', leds= config.LEDS_UUIDS) 'leds.html', leds= config.LEDS_UUIDS)
@ -62,23 +25,30 @@ def get():
def set_led(): def set_led():
""" """
Reçoit une commande pour allumer ou éteindre une LED. Reçoit une commande pour allumer ou éteindre une LED.
Attend un JSON : { "led": "led1", "state": "on" } ou { "led": "led2", "state": "off" } Attend un JSON : { "led": "14", "state": "on" } ou { "led": "15, "state": "off" }
""" """
data = request.get_json() data = request.get_json()
led = data.get('led') led = data.get('led')
state = data.get('state') state = data.get('state')
# get the controller (lazy, stored on app.extensions) # get the controller (lazy, stored on app.extensions)
gpio_leds = _get_gpio_leds() gpio_leds = leds.get()
try: try:
# parse led id/name according to your naming convention # parse led id/name according to your naming convention
print([x.gpio_pin for x in gpio_leds.leds])
gpio_led = gpio_leds.get_by_uuid(int(led)) gpio_led = gpio_leds.get_by_uuid(int(led))
print(f"Setting {led} / {gpio_led} to {state}") print(f"Setting {led} / {gpio_led} to {state}")
if state == "on": if state == "on":
gpio_led.on() gpio_led.on()
else: else:
gpio_led.off() gpio_led.off()
except Exception as e:
return jsonify({'status': 'error', 'error': str(e)}), 400
return jsonify({'status': 'ok', 'led': led, 'state': state}) except Exception as e:
raise
print(f'{e}')
return jsonify({'status': 'error', 'error': 'error'}), 400
print(f"Commande reçue pour {led} : {state}")
return jsonify({'status': 'ok', 'led': led, 'state': state})

View File

@ -23,7 +23,12 @@
</a> </a>
</div> </div>
<div id="navbarBasicExample" class="navbar-menu"> <div id="navbarBasicExample" class="navbar-menu">
<div class="navbar-start">
<a class="navbar-item" href="/camera">Caméra</a>
<a class="navbar-item" href="/leds">Leds</a>
</div>
<div class="navbar-end"> <div class="navbar-end">
<a class="navbar-item" href="/restart">Redémarrer le serveur</a>
<a id="calibration-tag-0" class="calibration-tag navbar-item" href="/calibration/calibrate" {% if calibration.state != 0 %}style="display: none;"{% endif %}> <a id="calibration-tag-0" class="calibration-tag navbar-item" href="/calibration/calibrate" {% if calibration.state != 0 %}style="display: none;"{% endif %}>
<span id="calibration-tag-0" class="tags has-addons"> <span id="calibration-tag-0" class="tags has-addons">
<span class="tag is-dark">étalonnage</span> <span class="tag is-dark">étalonnage</span>

View File

@ -11,7 +11,7 @@
</div> </div>
<div class="column is-half has-text-centered"> <div class="column is-half has-text-centered">
<figure class="image is-4by3" style="max-width: 480px; margin: auto;"> <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" <img class="preview" id="camera-preview" alt="Camera Preview"
style="border: 1px solid #ccc;"> style="border: 1px solid #ccc;">
</figure> </figure>
<button class="button is-small is-info mt-2" type="button" onclick="refreshPreview()">Rafraîchir <button class="button is-small is-info mt-2" type="button" onclick="refreshPreview()">Rafraîchir
@ -64,13 +64,16 @@
body: JSON.stringify({ [paramName]: value }) body: JSON.stringify({ [paramName]: value })
}).then(() => location.reload()); }).then(() => location.reload());
} }
const img = document.getElementById('camera-preview');
img.onload = function() {
requestAnimationFrame(refreshPreview);
}
window.refreshPreview = function () { function refreshPreview() {
const img = document.getElementById('camera-preview'); const url = '/camera/feed.jpg?' + new Date().getTime();
const url = '/static/feed.jpg?' + new Date().getTime();
img.src = url; img.src = url;
} }
setInterval(window.refreshPreview, 1000); // Refresh every second
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
fetch('/camera/config') fetch('/camera/config')
@ -146,7 +149,11 @@
}); });
container.appendChild(box); container.appendChild(box);
}); });
});
// When ready, load preview
refreshPreview();
});
}); });
function CapturePreview() { function CapturePreview() {
fetch('/camera/capture_preview', {method: 'POST'}) fetch('/camera/capture_preview', {method: 'POST'})
@ -160,4 +167,4 @@
}); });
} }
</script> </script>
{% endblock extrajs %} {% endblock extrajs %}