You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
280 lines
9.2 KiB
280 lines
9.2 KiB
""" |
|
ocp_screenshot.py |
|
================= |
|
Screenshot d'un modèle build123d via OCP (OCCT), fond transparent. |
|
|
|
Dépendances : build123d, Pillow, numpy |
|
Nécessite : Xvfb (sudo apt install xvfb) |
|
|
|
Lancement |
|
--------- |
|
# Directement (si DISPLAY est déjà dispo) : |
|
python ocp_screenshot.py |
|
|
|
# Headless (sans serveur X) : |
|
xvfb-run -a python ocp_screenshot.py |
|
|
|
API |
|
--- |
|
from ocp_screenshot import screenshot |
|
|
|
screenshot( |
|
shape, # build123d Shape / Compound / Part |
|
"output.png", |
|
width=800, height=600, |
|
orientation="ZupRight", # voir ORIENTATIONS ci-dessous |
|
fit_padding=0.05, # marge autour du modèle (0–1) |
|
background=None, # None = transparent, ou "#RRGGBB" |
|
color="#A8C4DC", # couleur du solide |
|
line_color="#1a1a1a", # couleur des arêtes |
|
show_edges=True, |
|
) |
|
|
|
Orientations disponibles |
|
------------------------ |
|
ZupRight → isométrique Z-haut, côté droit (défaut, standard build123d) |
|
ZupLeft → isométrique Z-haut, côté gauche |
|
YupRight → isométrique Y-haut, côté droit |
|
YupLeft → isométrique Y-haut, côté gauche |
|
Top / Bottom / Front / Back / Left / Right |
|
""" |
|
|
|
import subprocess, sys, os, tempfile |
|
import numpy as np |
|
from PIL import Image |
|
|
|
# ── Orientations ──────────────────────────────────────────────────────────── |
|
from OCP.V3d import V3d_TypeOfOrientation as _Ori |
|
|
|
ORIENTATIONS = { |
|
"ZupRight" : _Ori.V3d_TypeOfOrientation_Zup_AxoRight, |
|
"ZupLeft" : _Ori.V3d_TypeOfOrientation_Zup_AxoLeft, |
|
"YupRight" : _Ori.V3d_TypeOfOrientation_Yup_AxoRight, |
|
"YupLeft" : _Ori.V3d_TypeOfOrientation_Yup_AxoLeft, |
|
"Top" : _Ori.V3d_Zpos, |
|
"Bottom" : _Ori.V3d_Zneg, |
|
"Front" : _Ori.V3d_Xpos, |
|
"Back" : _Ori.V3d_Xneg, |
|
"Left" : _Ori.V3d_Ypos, |
|
"Right" : _Ori.V3d_Yneg, |
|
} |
|
|
|
|
|
def _hex_to_occ_color(hex_str: str): |
|
"""Convertit '#RRGGBB' en Quantity_Color OCC.""" |
|
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB |
|
h = hex_str.lstrip("#") |
|
r, g, b = (int(h[i:i+2], 16) / 255.0 for i in (0, 2, 4)) |
|
return Quantity_Color(r, g, b, Quantity_TOC_RGB) |
|
|
|
|
|
_OCC_SESSION: dict = {} # conn + driver + viewer réutilisés dans le même process |
|
|
|
|
|
def _make_viewer(width: int, height: int): |
|
""" |
|
Crée (ou réutilise) un viewer OCC offscreen. |
|
|
|
Aspect_DisplayConnection ne peut être instancié qu'une fois par process |
|
sous Xvfb — on conserve donc la connexion et le driver en cache de module. |
|
Chaque appel crée en revanche une nouvelle View (fenêtre offscreen). |
|
""" |
|
from OCP.Aspect import Aspect_DisplayConnection |
|
from OCP.OpenGl import OpenGl_GraphicDriver |
|
from OCP.V3d import V3d_Viewer |
|
from OCP.Xw import Xw_Window |
|
|
|
if "viewer" not in _OCC_SESSION: |
|
conn = Aspect_DisplayConnection() |
|
driver = OpenGl_GraphicDriver(conn, False) |
|
viewer = V3d_Viewer(driver) |
|
viewer.SetDefaultLights() |
|
viewer.SetLightOn() |
|
_OCC_SESSION["conn"] = conn |
|
_OCC_SESSION["driver"] = driver |
|
_OCC_SESSION["viewer"] = viewer |
|
|
|
viewer = _OCC_SESSION["viewer"] |
|
conn = _OCC_SESSION["conn"] |
|
|
|
view = viewer.CreateView() |
|
win = Xw_Window(conn, "ocp_screenshot", 0, 0, width, height) |
|
view.SetWindow(win) |
|
win.Map() |
|
|
|
return viewer, view |
|
|
|
|
|
def _render_to_array(view, shape_occ, ctx, width: int, height: int, |
|
bg_color, orientation, fit_padding: float) -> np.ndarray: |
|
"""Configure la vue, lance le rendu, retourne un array HxWx3 uint8.""" |
|
from OCP.Image import Image_AlienPixMap |
|
from OCP.TCollection import TCollection_AsciiString |
|
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB |
|
|
|
view.SetBackgroundColor(bg_color) |
|
view.SetProj(orientation) |
|
view.FitAll(fit_padding) |
|
view.Redraw() |
|
|
|
pix = Image_AlienPixMap() |
|
view.ToPixMap(pix, width, height) |
|
|
|
# Sauvegarde temporaire puis lecture Pillow |
|
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: |
|
tmp = f.name |
|
pix.Save(TCollection_AsciiString(tmp)) |
|
arr = np.array(Image.open(tmp).convert("RGB"), dtype=np.float32) |
|
os.unlink(tmp) |
|
return arr |
|
|
|
|
|
def _double_render_transparent(view, width, height, orientation, |
|
fit_padding) -> np.ndarray: |
|
""" |
|
Technique double-render blanc/noir → canal alpha propre. |
|
|
|
Principe : sur fond blanc Cw = C·α + 1·(1−α) = C·α + 1 − α |
|
sur fond noir Cb = C·α |
|
donc α = 1 − (Cw − Cb) et C = Cb / α |
|
""" |
|
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB |
|
|
|
white = Quantity_Color(1.0, 1.0, 1.0, Quantity_TOC_RGB) |
|
black = Quantity_Color(0.0, 0.0, 0.0, Quantity_TOC_RGB) |
|
|
|
w = _render_to_array(view, None, None, width, height, |
|
white, orientation, fit_padding) |
|
b = _render_to_array(view, None, None, width, height, |
|
black, orientation, fit_padding) |
|
|
|
# Alpha (par canal, on prend le max pour être robuste aux artefacts) |
|
alpha = 1.0 - (w - b) / 255.0 |
|
alpha = np.clip(alpha.max(axis=2), 0.0, 1.0) |
|
|
|
# Couleur pré-multipliée → non-pré-multipliée |
|
a3 = alpha[..., np.newaxis] |
|
rgb = np.where(a3 > 0.01, b / np.maximum(a3, 0.01), 0.0) |
|
rgb = np.clip(rgb, 0, 255).astype(np.uint8) |
|
|
|
return np.dstack([rgb, (alpha * 255).astype(np.uint8)]) # RGBA |
|
|
|
|
|
def screenshot( |
|
shape, |
|
output_path: str, |
|
width: int = 800, |
|
height: int = 600, |
|
orientation: str = "ZupRight", |
|
fit_padding: float = 0.05, |
|
background: "str | None" = None, |
|
color: str = "#A8C4DC", |
|
line_color: str = "#1a1a1a", |
|
show_edges: bool = True, |
|
): |
|
""" |
|
Génère un screenshot PNG du modèle build123d. |
|
|
|
Parameters |
|
---------- |
|
shape : build123d Shape (Solid, Compound, Part…) |
|
output_path : chemin de sortie (.png) |
|
width/height : dimensions en pixels |
|
orientation : voir ORIENTATIONS (défaut "ZupRight") |
|
fit_padding : marge relative autour du modèle (0.05 = 5 %) |
|
background : None → fond transparent | "#RRGGBB" → fond coloré |
|
color : couleur du solide (hex) |
|
line_color : couleur des arêtes (hex) |
|
show_edges : afficher les arêtes |
|
""" |
|
from OCP.AIS import AIS_InteractiveContext, AIS_Shape |
|
from OCP.Prs3d import Prs3d_Drawer |
|
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB |
|
from OCP.Aspect import Aspect_TypeOfLine |
|
|
|
if orientation not in ORIENTATIONS: |
|
raise ValueError( |
|
f"Orientation '{orientation}' inconnue. " |
|
f"Choisir parmi : {list(ORIENTATIONS.keys())}" |
|
) |
|
ori = ORIENTATIONS[orientation] |
|
|
|
# Extraire le TopoDS_Shape depuis build123d |
|
occ_shape = shape.wrapped |
|
|
|
viewer, view = _make_viewer(width, height) |
|
ctx = AIS_InteractiveContext(viewer) |
|
|
|
# Couleur du solide |
|
ais = AIS_Shape(occ_shape) |
|
ais.SetColor(_hex_to_occ_color(color)) |
|
ais.SetTransparency(0.0) |
|
|
|
if show_edges: |
|
# Configurer les arêtes dans le drawer |
|
drawer = ais.Attributes() |
|
drawer.SetFaceBoundaryDraw(True) |
|
drawer.FaceBoundaryAspect().SetColor(_hex_to_occ_color(line_color)) |
|
drawer.FaceBoundaryAspect().SetWidth(1.0) |
|
|
|
ctx.Display(ais, True) |
|
view.SetProj(ori) |
|
view.FitAll(fit_padding) |
|
|
|
if background is None: |
|
# Fond transparent via double-render |
|
rgba = _double_render_transparent(view, width, height, ori, fit_padding) |
|
Image.fromarray(rgba, "RGBA").save(output_path) |
|
else: |
|
# Fond coloré direct |
|
from OCP.Image import Image_AlienPixMap |
|
from OCP.TCollection import TCollection_AsciiString |
|
view.SetBackgroundColor(_hex_to_occ_color(background)) |
|
view.Redraw() |
|
pix = Image_AlienPixMap() |
|
view.ToPixMap(pix, width, height) |
|
pix.Save(TCollection_AsciiString(output_path)) |
|
|
|
print(f"✓ {output_path} ({width}×{height}px, orientation={orientation})") |
|
|
|
|
|
# ───────────────────────────────────────────── |
|
# Exemple d'utilisation |
|
# ───────────────────────────────────────────── |
|
if __name__ == "__main__": |
|
from build123d import * |
|
|
|
# Modèle simple : boîte + cylindre (Compound) |
|
with BuildPart() as p: |
|
Box(40, 30, 15) |
|
with Locations((0, 0, 15)): |
|
Cylinder(8, 20) |
|
|
|
# Fond transparent |
|
screenshot( |
|
p.part, |
|
"model_transparent.png", |
|
width=800, height=600, |
|
orientation="ZupRight", |
|
color="#5B8DB8", |
|
) |
|
|
|
# Fond coloré |
|
screenshot( |
|
p.part, |
|
"model_bg.png", |
|
width=800, height=600, |
|
orientation="ZupRight", |
|
background="#F0EDE6", |
|
color="#5B8DB8", |
|
) |
|
|
|
# Quelques orientations |
|
for ori in ["Top", "Front", "ZupLeft"]: |
|
screenshot( |
|
p.part, |
|
f"model_{ori.lower()}.png", |
|
width=600, height=450, |
|
orientation=ori, |
|
color="#C0392B", |
|
)
|
|
|