feat: curseur tolérance verre pour récupérer les zones semi-transparentes du détourage
Ajoute alphaThreshold (défaut 30) — les pixels dont l'alpha dépasse ce seuil sont rendus opaques avant la composition sur fond blanc, évitant que le verre ou les surfaces translucides soient supprimés par le modèle IA. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@ import { useImages } from '@/hooks/useImages'
|
||||
import type { ImageItem } from '@/types/images'
|
||||
|
||||
function ImagesPageInner() {
|
||||
const { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem, assignProduct } = useImages()
|
||||
const { items, addFiles, setFeather, setAlphaThreshold, rotate, uploadOne, uploadAll, removeItem, assignProduct } = useImages()
|
||||
const [lightboxItem, setLightboxItem] = useState<ImageItem | null>(null)
|
||||
|
||||
return (
|
||||
@@ -38,6 +38,7 @@ function ImagesPageInner() {
|
||||
key={item.id}
|
||||
item={item}
|
||||
onFeatherChange={setFeather}
|
||||
onAlphaThresholdChange={setAlphaThreshold}
|
||||
onRotate={rotate}
|
||||
onUpload={uploadOne}
|
||||
onRemove={removeItem}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ImageItem } from '@/types/images'
|
||||
interface ImageCardProps {
|
||||
item: ImageItem
|
||||
onFeatherChange: (id: string, value: number) => void
|
||||
onAlphaThresholdChange: (id: string, value: number) => void
|
||||
onRotate: (id: string, direction: 'cw' | 'ccw') => void
|
||||
onUpload: (id: string) => void
|
||||
onRemove: (id: string) => void
|
||||
@@ -94,6 +95,7 @@ function ProductSearch({ itemId, onAssign }: { itemId: string; onAssign: (id: st
|
||||
export function ImageCard({
|
||||
item,
|
||||
onFeatherChange,
|
||||
onAlphaThresholdChange,
|
||||
onRotate,
|
||||
onUpload,
|
||||
onRemove,
|
||||
@@ -148,7 +150,7 @@ export function ImageCard({
|
||||
<ProductSearch itemId={item.id} onAssign={onAssignProduct} />
|
||||
)}
|
||||
|
||||
{/* Contrôles feather + rotation */}
|
||||
{/* Contrôles feather + tolérance + rotation */}
|
||||
<div className={canAdjust ? '' : 'opacity-40 pointer-events-none'}>
|
||||
<label className="block text-xs text-slate-500 mb-1">
|
||||
Bords : {item.feather} px
|
||||
@@ -163,6 +165,19 @@ export function ImageCard({
|
||||
className="w-full accent-indigo-500"
|
||||
/>
|
||||
|
||||
<label className="block text-xs text-slate-500 mb-1 mt-2">
|
||||
Tolérance verre : {item.alphaThreshold}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={item.alphaThreshold}
|
||||
onChange={(e) => onAlphaThresholdChange(item.id, Number(e.target.value))}
|
||||
className="w-full accent-amber-500"
|
||||
/>
|
||||
|
||||
<div className="flex gap-1 mt-2">
|
||||
<button
|
||||
onClick={() => onRotate(item.id, 'ccw')}
|
||||
|
||||
+22
-11
@@ -90,7 +90,7 @@ export function useImages() {
|
||||
|
||||
// Pipeline complet pour un item (avec ref)
|
||||
const runPipeline = useCallback(
|
||||
async (id: string, blob: Blob, ref: string, feather: number, rotation: number) => {
|
||||
async (id: string, blob: Blob, ref: string, feather: number, rotation: number, alphaThreshold: number) => {
|
||||
const { convertHeicToJpeg } = await import('@/lib/client/heicConverter')
|
||||
const { removeBackgroundRaw, compositeOnWhite } = await import('@/lib/client/bgRemover')
|
||||
const { rotateBlob } = await import('@/lib/client/imageRotator')
|
||||
@@ -120,7 +120,7 @@ export function useImages() {
|
||||
|
||||
let finalBlob: Blob
|
||||
try {
|
||||
const composited = await compositeOnWhite(rawPng, feather)
|
||||
const composited = await compositeOnWhite(rawPng, feather, alphaThreshold)
|
||||
finalBlob = await rotateBlob(composited, rotation)
|
||||
} catch (err) {
|
||||
update(id, { status: 'error', error: `Post-traitement : ${(err as Error).message}` })
|
||||
@@ -133,15 +133,15 @@ export function useImages() {
|
||||
[update, searchProduct],
|
||||
)
|
||||
|
||||
// Recomposite rapide (feather ou rotation changés) — ne relance pas l'IA
|
||||
// Recomposite rapide (feather, alphaThreshold ou rotation changés) — ne relance pas l'IA
|
||||
const recomposite = useCallback(
|
||||
async (id: string, rawPngBlob: Blob, feather: number, rotation: number) => {
|
||||
async (id: string, rawPngBlob: Blob, feather: number, rotation: number, alphaThreshold: number) => {
|
||||
const { compositeOnWhite } = await import('@/lib/client/bgRemover')
|
||||
const { rotateBlob } = await import('@/lib/client/imageRotator')
|
||||
try {
|
||||
const composited = await compositeOnWhite(rawPngBlob, feather)
|
||||
const composited = await compositeOnWhite(rawPngBlob, feather, alphaThreshold)
|
||||
const finalBlob = await rotateBlob(composited, rotation)
|
||||
update(id, { processedBlob: finalBlob, feather, rotation })
|
||||
update(id, { processedBlob: finalBlob, feather, rotation, alphaThreshold })
|
||||
} catch (err) {
|
||||
update(id, { status: 'error', error: `Retraitement : ${(err as Error).message}` })
|
||||
}
|
||||
@@ -167,6 +167,7 @@ export function useImages() {
|
||||
processedBlob: null,
|
||||
rotation: 0,
|
||||
feather: 2,
|
||||
alphaThreshold: 30,
|
||||
shopifyProductId: null,
|
||||
shopifyProductTitle: null,
|
||||
uploadedUrl: null,
|
||||
@@ -176,7 +177,7 @@ export function useImages() {
|
||||
}))
|
||||
dispatch({ type: 'ADD', items: newItems })
|
||||
for (const item of newItems) {
|
||||
runPipeline(item.id, item.originalBlob, item.ref, item.feather, item.rotation)
|
||||
runPipeline(item.id, item.originalBlob, item.ref, item.feather, item.rotation, item.alphaThreshold)
|
||||
}
|
||||
} else {
|
||||
const ref = refFromFilename(file.name)
|
||||
@@ -189,6 +190,7 @@ export function useImages() {
|
||||
processedBlob: null,
|
||||
rotation: 0,
|
||||
feather: 2,
|
||||
alphaThreshold: 30,
|
||||
shopifyProductId: null,
|
||||
shopifyProductTitle: null,
|
||||
uploadedUrl: null,
|
||||
@@ -197,7 +199,7 @@ export function useImages() {
|
||||
progress: null,
|
||||
}
|
||||
dispatch({ type: 'ADD', items: [newItem] })
|
||||
runPipeline(newItem.id, newItem.originalBlob, newItem.ref, newItem.feather, newItem.rotation)
|
||||
runPipeline(newItem.id, newItem.originalBlob, newItem.ref, newItem.feather, newItem.rotation, newItem.alphaThreshold)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -208,7 +210,16 @@ export function useImages() {
|
||||
(id: string, value: number) => {
|
||||
const item = items.find((i) => i.id === id)
|
||||
if (!item || !item.rawPngBlob) return
|
||||
recomposite(id, item.rawPngBlob, value, item.rotation)
|
||||
recomposite(id, item.rawPngBlob, value, item.rotation, item.alphaThreshold)
|
||||
},
|
||||
[items, recomposite],
|
||||
)
|
||||
|
||||
const setAlphaThreshold = useCallback(
|
||||
(id: string, value: number) => {
|
||||
const item = items.find((i) => i.id === id)
|
||||
if (!item || !item.rawPngBlob) return
|
||||
recomposite(id, item.rawPngBlob, item.feather, item.rotation, value)
|
||||
},
|
||||
[items, recomposite],
|
||||
)
|
||||
@@ -219,7 +230,7 @@ export function useImages() {
|
||||
if (!item || !item.rawPngBlob) return
|
||||
const delta = direction === 'cw' ? 90 : -90
|
||||
const newRotation = ((item.rotation + delta) % 360 + 360) % 360
|
||||
recomposite(id, item.rawPngBlob, item.feather, newRotation)
|
||||
recomposite(id, item.rawPngBlob, item.feather, newRotation, item.alphaThreshold)
|
||||
},
|
||||
[items, recomposite],
|
||||
)
|
||||
@@ -269,5 +280,5 @@ export function useImages() {
|
||||
[update],
|
||||
)
|
||||
|
||||
return { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem, assignProduct }
|
||||
return { items, addFiles, setFeather, setAlphaThreshold, rotate, uploadOne, uploadAll, removeItem, assignProduct }
|
||||
}
|
||||
|
||||
@@ -38,8 +38,9 @@ export async function removeBackgroundRaw(
|
||||
* Composite un PNG transparent sur fond blanc avec feather optionnel.
|
||||
* @param pngBlob — PNG avec transparence (résultat de removeBackgroundRaw)
|
||||
* @param feather — récupération des bords en px (0 = strict, 5 = doux)
|
||||
* @param alphaThreshold — pixels avec alpha > seuil deviennent opaques (0 = désactivé, ex: 30 pour récupérer le verre)
|
||||
*/
|
||||
export function compositeOnWhite(pngBlob: Blob, feather: number): Promise<Blob> {
|
||||
export function compositeOnWhite(pngBlob: Blob, feather: number, alphaThreshold = 0): Promise<Blob> {
|
||||
const objectUrl = URL.createObjectURL(pngBlob)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -55,6 +56,16 @@ export function compositeOnWhite(pngBlob: Blob, feather: number): Promise<Blob>
|
||||
|
||||
fgCtx.drawImage(img, 0, 0)
|
||||
|
||||
// Remonte les pixels semi-transparents (ex: verre) à pleine opacité
|
||||
if (alphaThreshold > 0) {
|
||||
const imageData = fgCtx.getImageData(0, 0, width, height)
|
||||
const data = imageData.data
|
||||
for (let i = 3; i < data.length; i += 4) {
|
||||
if (data[i] > alphaThreshold) data[i] = 255
|
||||
}
|
||||
fgCtx.putImageData(imageData, 0, 0)
|
||||
}
|
||||
|
||||
if (feather > 0) {
|
||||
fgCtx.globalCompositeOperation = 'destination-over'
|
||||
fgCtx.filter = `blur(${feather}px)`
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface ImageItem {
|
||||
processedBlob: Blob | null // JPEG final sur fond blanc, pivoté (pour upload + vue "après")
|
||||
rotation: number // 0, 90, 180, 270
|
||||
feather: number // 0–5, défaut 2
|
||||
alphaThreshold: number // 0–100, défaut 30 — pixels semi-transparents rendus opaques
|
||||
shopifyProductId: string | null
|
||||
shopifyProductTitle: string | null
|
||||
uploadedUrl: string | null
|
||||
|
||||
Reference in New Issue
Block a user